mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 15:19:18 +00:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cdef9185 | ||
|
|
9854ed8c9c | ||
|
|
52544a2c69 | ||
|
|
5ce433e235 | ||
|
|
c7c14d2332 | ||
|
|
6fdd482649 | ||
|
|
d390236318 | ||
|
|
ed8ee29732 | ||
|
|
ffc583e9d5 | ||
|
|
f1ad0c9c96 | ||
|
|
8fa3407649 | ||
|
|
d3282822fc | ||
|
|
554bcade24 | ||
|
|
a662c75de1 | ||
|
|
931614fe64 | ||
|
|
d39662f65f | ||
|
|
acf5fdebf8 | ||
|
|
7f7b1c13c0 | ||
|
|
75f09034ff | ||
|
|
549a50220b | ||
|
|
4189a2cfbe | ||
|
|
48895a9bb1 | ||
|
|
891f126ed6 | ||
|
|
4d3475a849 | ||
|
|
b475db66df | ||
|
|
a625a86e3e | ||
|
|
37e0f1037c | ||
|
|
a242253145 | ||
|
|
448e0074b7 | ||
|
|
304827fc8d | ||
|
|
872b3d781f | ||
|
|
813400f2b4 | ||
|
|
b6dfe2b944 | ||
|
|
8807865649 | ||
|
|
5fc7eb7586 | ||
|
|
9bd72b48e1 | ||
|
|
42b78ab4c1 | ||
|
|
9645609a05 | ||
|
|
a2a827d7ac | ||
|
|
bb3ca645a4 | ||
|
|
37ee43d19a | ||
|
|
bc30f23e34 | ||
|
|
166d84afe1 | ||
|
|
1b43c53015 | ||
|
|
d4415f5a35 | ||
|
|
0260cbbedb | ||
|
|
7c440d10ab | ||
|
|
c85c49817d | ||
|
|
c70eafa5f0 | ||
|
|
b64466d443 | ||
|
|
ef3f24ed48 | ||
|
|
2a8e8d014b | ||
|
|
e0da1c7217 | ||
|
|
51d3e61723 | ||
|
|
6b5765bbf3 | ||
|
|
eb1f3fbe1c | ||
|
|
fb93b1cd94 | ||
|
|
9aeffebde1 |
@@ -1,62 +0,0 @@
|
|||||||
# unilabos: Production package (depends on unilabos-env + pip unilabos)
|
|
||||||
# For production deployment
|
|
||||||
|
|
||||||
package:
|
|
||||||
name: unilabos
|
|
||||||
version: 0.10.19
|
|
||||||
|
|
||||||
source:
|
|
||||||
path: ../../unilabos
|
|
||||||
target_directory: unilabos
|
|
||||||
|
|
||||||
build:
|
|
||||||
python:
|
|
||||||
entry_points:
|
|
||||||
- unilab = unilabos.app.main:main
|
|
||||||
script:
|
|
||||||
- set PIP_NO_INDEX=
|
|
||||||
- if: win
|
|
||||||
then:
|
|
||||||
- copy %RECIPE_DIR%\..\..\MANIFEST.in %SRC_DIR%
|
|
||||||
- copy %RECIPE_DIR%\..\..\setup.cfg %SRC_DIR%
|
|
||||||
- copy %RECIPE_DIR%\..\..\setup.py %SRC_DIR%
|
|
||||||
- pip install %SRC_DIR%
|
|
||||||
- if: unix
|
|
||||||
then:
|
|
||||||
- cp $RECIPE_DIR/../../MANIFEST.in $SRC_DIR
|
|
||||||
- cp $RECIPE_DIR/../../setup.cfg $SRC_DIR
|
|
||||||
- cp $RECIPE_DIR/../../setup.py $SRC_DIR
|
|
||||||
- pip install $SRC_DIR
|
|
||||||
|
|
||||||
requirements:
|
|
||||||
host:
|
|
||||||
- python ==3.11.14
|
|
||||||
- pip
|
|
||||||
- setuptools
|
|
||||||
- zstd
|
|
||||||
- zstandard
|
|
||||||
run:
|
|
||||||
- zstd
|
|
||||||
- zstandard
|
|
||||||
- networkx
|
|
||||||
- typing_extensions
|
|
||||||
- websockets
|
|
||||||
- pint
|
|
||||||
- fastapi
|
|
||||||
- jinja2
|
|
||||||
- requests
|
|
||||||
- uvicorn
|
|
||||||
- if: not osx
|
|
||||||
then:
|
|
||||||
- opcua
|
|
||||||
- pyserial
|
|
||||||
- pandas
|
|
||||||
- pymodbus
|
|
||||||
- matplotlib
|
|
||||||
- pylibftdi
|
|
||||||
- uni-lab::unilabos-env ==0.10.19
|
|
||||||
|
|
||||||
about:
|
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
|
||||||
license: GPL-3.0-only
|
|
||||||
description: "UniLabOS - Production package with minimal ROS2 dependencies"
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# unilabos-env: conda environment dependencies (ROS2 + conda packages)
|
|
||||||
|
|
||||||
package:
|
|
||||||
name: unilabos-env
|
|
||||||
version: 0.10.19
|
|
||||||
|
|
||||||
build:
|
|
||||||
noarch: generic
|
|
||||||
|
|
||||||
requirements:
|
|
||||||
run:
|
|
||||||
# Python
|
|
||||||
- zstd
|
|
||||||
- zstandard
|
|
||||||
- conda-forge::python ==3.11.14
|
|
||||||
- conda-forge::opencv
|
|
||||||
# ROS2 dependencies (from ci-check.yml)
|
|
||||||
- robostack-staging::ros-humble-ros-core
|
|
||||||
- robostack-staging::ros-humble-action-msgs
|
|
||||||
- robostack-staging::ros-humble-std-msgs
|
|
||||||
- robostack-staging::ros-humble-geometry-msgs
|
|
||||||
- robostack-staging::ros-humble-control-msgs
|
|
||||||
- robostack-staging::ros-humble-nav2-msgs
|
|
||||||
- robostack-staging::ros-humble-cv-bridge
|
|
||||||
- robostack-staging::ros-humble-vision-opencv
|
|
||||||
- robostack-staging::ros-humble-tf-transformations
|
|
||||||
- robostack-staging::ros-humble-moveit-msgs
|
|
||||||
- robostack-staging::ros-humble-tf2-ros
|
|
||||||
- robostack-staging::ros-humble-tf2-ros-py
|
|
||||||
- conda-forge::transforms3d
|
|
||||||
- conda-forge::uv
|
|
||||||
|
|
||||||
# UniLabOS custom messages
|
|
||||||
- uni-lab::ros-humble-unilabos-msgs
|
|
||||||
|
|
||||||
about:
|
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
|
||||||
license: GPL-3.0-only
|
|
||||||
description: "UniLabOS Environment - ROS2 and conda dependencies"
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# unilabos-full: Full package with all features
|
|
||||||
# Depends on unilabos + complete ROS2 desktop + dev tools
|
|
||||||
|
|
||||||
package:
|
|
||||||
name: unilabos-full
|
|
||||||
version: 0.10.19
|
|
||||||
|
|
||||||
build:
|
|
||||||
noarch: generic
|
|
||||||
|
|
||||||
requirements:
|
|
||||||
run:
|
|
||||||
# Base unilabos package (includes unilabos-env)
|
|
||||||
- uni-lab::unilabos ==0.10.19
|
|
||||||
# Documentation tools
|
|
||||||
- sphinx
|
|
||||||
- sphinx_rtd_theme
|
|
||||||
# Web UI
|
|
||||||
- gradio
|
|
||||||
- flask
|
|
||||||
# Interactive development
|
|
||||||
- ipython
|
|
||||||
- jupyter
|
|
||||||
- jupyros
|
|
||||||
- colcon-common-extensions
|
|
||||||
# ROS2 full desktop (includes rviz2, gazebo, etc.)
|
|
||||||
- robostack-staging::ros-humble-desktop-full
|
|
||||||
# Navigation and motion control
|
|
||||||
- ros-humble-navigation2
|
|
||||||
- ros-humble-ros2-control
|
|
||||||
- ros-humble-robot-state-publisher
|
|
||||||
- ros-humble-joint-state-publisher
|
|
||||||
# MoveIt motion planning
|
|
||||||
- ros-humble-moveit
|
|
||||||
- ros-humble-moveit-servo
|
|
||||||
# Simulation
|
|
||||||
- ros-humble-simulation
|
|
||||||
|
|
||||||
about:
|
|
||||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
|
||||||
license: GPL-3.0-only
|
|
||||||
description: "UniLabOS Full - Complete package with ROS2 Desktop, MoveIt, Navigation2, Gazebo, Jupyter"
|
|
||||||
92
.conda/recipe.yaml
Normal file
92
.conda/recipe.yaml
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package:
|
||||||
|
name: unilabos
|
||||||
|
version: 0.10.12
|
||||||
|
|
||||||
|
source:
|
||||||
|
path: ../unilabos
|
||||||
|
target_directory: unilabos
|
||||||
|
|
||||||
|
build:
|
||||||
|
python:
|
||||||
|
entry_points:
|
||||||
|
- unilab = unilabos.app.main:main
|
||||||
|
script:
|
||||||
|
- set PIP_NO_INDEX=
|
||||||
|
- if: win
|
||||||
|
then:
|
||||||
|
- copy %RECIPE_DIR%\..\MANIFEST.in %SRC_DIR%
|
||||||
|
- copy %RECIPE_DIR%\..\setup.cfg %SRC_DIR%
|
||||||
|
- copy %RECIPE_DIR%\..\setup.py %SRC_DIR%
|
||||||
|
- call %PYTHON% -m pip install %SRC_DIR%
|
||||||
|
- if: unix
|
||||||
|
then:
|
||||||
|
- cp $RECIPE_DIR/../MANIFEST.in $SRC_DIR
|
||||||
|
- cp $RECIPE_DIR/../setup.cfg $SRC_DIR
|
||||||
|
- cp $RECIPE_DIR/../setup.py $SRC_DIR
|
||||||
|
- $PYTHON -m pip install $SRC_DIR
|
||||||
|
|
||||||
|
|
||||||
|
requirements:
|
||||||
|
host:
|
||||||
|
- python ==3.11.11
|
||||||
|
- pip
|
||||||
|
- setuptools
|
||||||
|
- zstd
|
||||||
|
- zstandard
|
||||||
|
run:
|
||||||
|
- conda-forge::python ==3.11.11
|
||||||
|
- compilers
|
||||||
|
- cmake
|
||||||
|
- zstd
|
||||||
|
- zstandard
|
||||||
|
- ninja
|
||||||
|
- if: unix
|
||||||
|
then:
|
||||||
|
- make
|
||||||
|
- sphinx
|
||||||
|
- sphinx_rtd_theme
|
||||||
|
- numpy
|
||||||
|
- scipy
|
||||||
|
- pandas
|
||||||
|
- networkx
|
||||||
|
- matplotlib
|
||||||
|
- pint
|
||||||
|
- pyserial
|
||||||
|
- pyusb
|
||||||
|
- pylibftdi
|
||||||
|
- pymodbus
|
||||||
|
- python-can
|
||||||
|
- pyvisa
|
||||||
|
- opencv
|
||||||
|
- pydantic
|
||||||
|
- fastapi
|
||||||
|
- uvicorn
|
||||||
|
- gradio
|
||||||
|
- flask
|
||||||
|
- websockets
|
||||||
|
- ipython
|
||||||
|
- jupyter
|
||||||
|
- jupyros
|
||||||
|
- colcon-common-extensions
|
||||||
|
- robostack-staging::ros-humble-desktop-full
|
||||||
|
- robostack-staging::ros-humble-control-msgs
|
||||||
|
- robostack-staging::ros-humble-sensor-msgs
|
||||||
|
- robostack-staging::ros-humble-trajectory-msgs
|
||||||
|
- ros-humble-navigation2
|
||||||
|
- ros-humble-ros2-control
|
||||||
|
- ros-humble-robot-state-publisher
|
||||||
|
- ros-humble-joint-state-publisher
|
||||||
|
- ros-humble-rosbridge-server
|
||||||
|
- ros-humble-cv-bridge
|
||||||
|
- ros-humble-tf2
|
||||||
|
- ros-humble-moveit
|
||||||
|
- ros-humble-moveit-servo
|
||||||
|
- ros-humble-simulation
|
||||||
|
- ros-humble-tf-transformations
|
||||||
|
- transforms3d
|
||||||
|
- uni-lab::ros-humble-unilabos-msgs
|
||||||
|
|
||||||
|
about:
|
||||||
|
repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||||
|
license: GPL-3.0-only
|
||||||
|
description: "Uni-Lab-OS"
|
||||||
9
.conda/scripts/post-link.bat
Normal file
9
.conda/scripts/post-link.bat
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
REM upgrade pip
|
||||||
|
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
||||||
|
|
||||||
|
REM install extra deps
|
||||||
|
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
||||||
|
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||||
9
.conda/scripts/post-link.sh
Normal file
9
.conda/scripts/post-link.sh
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
# make sure pip is available
|
||||||
|
"$PREFIX/bin/python" -m pip install --upgrade pip
|
||||||
|
|
||||||
|
# install extra deps
|
||||||
|
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
||||||
|
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-device
|
|
||||||
description: Guide for adding new devices to Uni-Lab-OS (接入新设备). Uses @device decorator + AST auto-scanning instead of manual YAML. Walks through device category, communication protocol, driver creation with decorators, and graph file setup. Use when the user wants to add/integrate a new device, create a device driver, write a device class, or mentions 接入设备/添加设备/设备驱动/物模型.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 添加新设备到 Uni-Lab-OS
|
|
||||||
|
|
||||||
**第一步:** 使用 Read 工具读取 `docs/ai_guides/add_device.md`,获取完整的设备接入指南。
|
|
||||||
|
|
||||||
该指南包含设备类别(物模型)列表、通信协议模板、常见错误检查清单等。搜索 `unilabos/devices/` 获取已有设备的实现参考。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 装饰器参考
|
|
||||||
|
|
||||||
### @device — 设备类装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device
|
|
||||||
|
|
||||||
# 单设备
|
|
||||||
@device(
|
|
||||||
id="my_device.vendor", # 注册表唯一标识(必填)
|
|
||||||
category=["temperature"], # 分类标签列表(必填)
|
|
||||||
description="设备描述", # 设备描述
|
|
||||||
display_name="显示名称", # UI 显示名称(默认用 id)
|
|
||||||
icon="DeviceIcon.webp", # 图标文件名
|
|
||||||
version="1.0.0", # 版本号
|
|
||||||
device_type="python", # "python" 或 "ros2"
|
|
||||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
|
||||||
model={...}, # 3D 模型配置
|
|
||||||
hardware_interface=HardwareInterface(...), # 硬件通信接口
|
|
||||||
)
|
|
||||||
|
|
||||||
# 多设备(同一个类注册多个设备 ID,各自有不同的 handles 等配置)
|
|
||||||
@device(
|
|
||||||
ids=["pump.vendor.model_A", "pump.vendor.model_B"],
|
|
||||||
id_meta={
|
|
||||||
"pump.vendor.model_A": {"handles": [...], "description": "型号 A"},
|
|
||||||
"pump.vendor.model_B": {"handles": [...], "description": "型号 B"},
|
|
||||||
},
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### @action — 动作方法装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import action
|
|
||||||
|
|
||||||
@action # 无参:注册为 UniLabJsonCommand 动作
|
|
||||||
@action() # 同上
|
|
||||||
@action(description="执行操作") # 带描述
|
|
||||||
@action(
|
|
||||||
action_type=HeatChill, # 指定 ROS Action 消息类型
|
|
||||||
goal={"temperature": "temp"}, # Goal 字段映射
|
|
||||||
feedback={}, # Feedback 字段映射
|
|
||||||
result={}, # Result 字段映射
|
|
||||||
handles=[...], # 动作级别端口
|
|
||||||
goal_default={"temp": 25.0}, # Goal 默认值
|
|
||||||
placeholder_keys={...}, # 参数占位符
|
|
||||||
always_free=True, # 不受排队限制
|
|
||||||
auto_prefix=True, # 强制使用 auto- 前缀
|
|
||||||
parent=True, # 从父类 MRO 获取参数签名
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**自动识别规则:**
|
|
||||||
- 带 `@action` 的公开方法 → 注册为动作(方法名即动作名)
|
|
||||||
- **不带 `@action` 的公开方法** → 自动注册为 `auto-{方法名}` 动作
|
|
||||||
- `_` 开头的方法 → 不扫描
|
|
||||||
- `@not_action` 标记的方法 → 排除
|
|
||||||
|
|
||||||
### @topic_config — 状态属性配置
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config(
|
|
||||||
period=5.0, # 发布周期(秒),默认 5.0
|
|
||||||
print_publish=False, # 是否打印发布日志
|
|
||||||
qos=10, # QoS 深度,默认 10
|
|
||||||
name="custom_name", # 自定义发布名称(默认用属性名)
|
|
||||||
)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self.data.get("temperature", 0.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 辅助装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import not_action, always_free
|
|
||||||
|
|
||||||
@not_action # 标记为非动作(post_init、辅助方法等)
|
|
||||||
@always_free # 标记为不受排队限制(查询类操作)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 设备模板
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
|
||||||
from unilabos.registry.decorators import device, action, topic_config, not_action
|
|
||||||
|
|
||||||
@device(id="my_device", category=["my_category"], description="设备描述")
|
|
||||||
class MyDevice:
|
|
||||||
_ros_node: BaseROS2DeviceNode
|
|
||||||
|
|
||||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
|
||||||
self.device_id = device_id or "my_device"
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
|
|
||||||
self.data: Dict[str, Any] = {"status": "Idle"}
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node: BaseROS2DeviceNode) -> None:
|
|
||||||
self._ros_node = ros_node
|
|
||||||
|
|
||||||
@action
|
|
||||||
async def initialize(self) -> bool:
|
|
||||||
self.data["status"] = "Ready"
|
|
||||||
return True
|
|
||||||
|
|
||||||
@action
|
|
||||||
async def cleanup(self) -> bool:
|
|
||||||
self.data["status"] = "Offline"
|
|
||||||
return True
|
|
||||||
|
|
||||||
@action(description="执行操作")
|
|
||||||
def my_action(self, param: float = 0.0, name: str = "") -> Dict[str, Any]:
|
|
||||||
"""带 @action 装饰器 → 注册为 'my_action' 动作"""
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
def get_info(self) -> Dict[str, Any]:
|
|
||||||
"""无 @action → 自动注册为 'auto-get_info' 动作"""
|
|
||||||
return {"device_id": self.device_id}
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
|
||||||
return self.data.get("status", "Idle")
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config(period=2.0)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self.data.get("temperature", 0.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 要点
|
|
||||||
|
|
||||||
- `_ros_node: BaseROS2DeviceNode` 类型标注放在类体顶部
|
|
||||||
- `__init__` 签名固定为 `(self, device_id=None, config=None, **kwargs)`
|
|
||||||
- `post_init` 用 `@not_action` 标记,参数类型标注为 `BaseROS2DeviceNode`
|
|
||||||
- 运行时状态存储在 `self.data` 字典中
|
|
||||||
- 设备文件放在 `unilabos/devices/<category>/` 目录下
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-resource
|
|
||||||
description: Guide for adding new resources (materials, bottles, carriers, decks, warehouses) to Uni-Lab-OS (添加新物料/资源). Uses @resource decorator for AST auto-scanning. Covers Bottle, Carrier, Deck, WareHouse definitions. Use when the user wants to add resources, define materials, create a deck layout, add bottles/carriers/plates, or mentions 物料/资源/resource/bottle/carrier/deck/plate/warehouse.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 添加新物料资源
|
|
||||||
|
|
||||||
Uni-Lab-OS 的资源体系基于 PyLabRobot,通过扩展实现 Bottle、Carrier、WareHouse、Deck 等实验室物料管理。使用 `@resource` 装饰器注册,AST 自动扫描生成注册表条目。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 资源类型
|
|
||||||
|
|
||||||
| 类型 | 基类 | 用途 | 示例 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| **Bottle** | `Well` (PyLabRobot) | 单个容器(瓶、小瓶、烧杯、反应器) | 试剂瓶、粉末瓶 |
|
|
||||||
| **BottleCarrier** | `ItemizedCarrier` | 多槽位载架(放多个 Bottle) | 6 位试剂架、枪头盒 |
|
|
||||||
| **WareHouse** | `ItemizedCarrier` | 堆栈/仓库(放多个 Carrier) | 4x4 堆栈 |
|
|
||||||
| **Deck** | `Deck` (PyLabRobot) | 工作站台面(放多个 WareHouse) | 反应站 Deck |
|
|
||||||
|
|
||||||
**层级关系:** `Deck` → `WareHouse` → `BottleCarrier` → `Bottle`
|
|
||||||
|
|
||||||
WareHouse 本质上和 Site 是同一概念 — 都是定义一组固定的放置位(slot),只不过 WareHouse 多嵌套了一层 Deck。两者都需要开发者根据实际物理尺寸自行计算各 slot 的偏移坐标。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## @resource 装饰器
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
@resource(
|
|
||||||
id="my_resource_id", # 注册表唯一标识(必填)
|
|
||||||
category=["bottles"], # 分类标签列表(必填)
|
|
||||||
description="资源描述",
|
|
||||||
icon="", # 图标
|
|
||||||
version="1.0.0",
|
|
||||||
handles=[...], # 端口列表(InputHandle / OutputHandle)
|
|
||||||
model={...}, # 3D 模型配置
|
|
||||||
class_type="pylabrobot", # "python" / "pylabrobot" / "unilabos"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 创建规范
|
|
||||||
|
|
||||||
### 命名规则
|
|
||||||
|
|
||||||
1. **`name` 参数作为前缀**:所有工厂函数必须接受 `name: str` 参数,创建子物料时以 `name` 作为前缀,确保实例名在运行时全局唯一
|
|
||||||
2. **Bottle 命名约定**:试剂瓶-Bottle,烧杯-Beaker,烧瓶-Flask,小瓶-Vial
|
|
||||||
3. **函数名 = `@resource(id=...)`**:工厂函数名与注册表 id 保持一致
|
|
||||||
|
|
||||||
### 子物料命名示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Carrier 内部的 sites 用 name 前缀
|
|
||||||
for k, v in sites.items():
|
|
||||||
v.name = f"{name}_{v.name}" # "堆栈1左_A01", "堆栈1左_B02" ...
|
|
||||||
|
|
||||||
# Carrier 中放置 Bottle 时用 name 前缀
|
|
||||||
carrier[0] = My_Reagent_Bottle(f"{name}_flask_1") # "堆栈1左_flask_1"
|
|
||||||
carrier[i] = My_Solid_Vial(f"{name}_vial_{ordering[i]}") # "堆栈1左_vial_A1"
|
|
||||||
|
|
||||||
# create_homogeneous_resources 使用 name_prefix
|
|
||||||
sites=create_homogeneous_resources(
|
|
||||||
klass=ResourceHolder,
|
|
||||||
locations=[...],
|
|
||||||
name_prefix=name, # 自动生成 "{name}_0", "{name}_1" ...
|
|
||||||
)
|
|
||||||
|
|
||||||
# Deck setup 中用仓库名称作为 name 传入
|
|
||||||
self.warehouses = {
|
|
||||||
"堆栈1左": my_warehouse_4x4("堆栈1左"), # WareHouse.name = "堆栈1左"
|
|
||||||
"试剂堆栈": my_reagent_stack("试剂堆栈"), # WareHouse.name = "试剂堆栈"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 其他规范
|
|
||||||
|
|
||||||
- **max_volume 单位为 μL**:500mL = 500000
|
|
||||||
- **尺寸单位为 mm**:`diameter`, `height`, `size_x/y/z`, `dx/dy/dz`
|
|
||||||
- **BottleCarrier 必须设置 `num_items_x/y/z`**:用于前端渲染布局
|
|
||||||
- **Deck 的 `__init__` 必须接受 `setup=False`**:图文件中 `config.setup=true` 触发 `setup()`
|
|
||||||
- **按项目分组文件**:同一工作站的资源放在 `unilabos/resources/<project>/` 下
|
|
||||||
- **`__init__` 必须接受 `serialize()` 输出的所有字段**:`serialize()` 输出会作为 `config` 回传到 `__init__`,因此必须通过显式参数或 `**kwargs` 接受,否则反序列化会报错
|
|
||||||
- **持久化运行时状态用 `serialize_state()`**:通过 `_unilabos_state` 字典存储可变信息(如物料内容、液体量),只存 JSON 可序列化的基本类型
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 资源模板
|
|
||||||
|
|
||||||
### Bottle
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
from unilabos.resources.itemized_carrier import Bottle
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="My_Reagent_Bottle", category=["bottles"], description="我的试剂瓶")
|
|
||||||
def My_Reagent_Bottle(
|
|
||||||
name: str,
|
|
||||||
diameter: float = 70.0,
|
|
||||||
height: float = 120.0,
|
|
||||||
max_volume: float = 500000.0,
|
|
||||||
barcode: str = None,
|
|
||||||
) -> Bottle:
|
|
||||||
return Bottle(
|
|
||||||
name=name,
|
|
||||||
diameter=diameter,
|
|
||||||
height=height,
|
|
||||||
max_volume=max_volume,
|
|
||||||
barcode=barcode,
|
|
||||||
model="My_Reagent_Bottle",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bottle 参数:**
|
|
||||||
- `name`: 实例名称(运行时唯一,由上层 Carrier 以前缀方式传入)
|
|
||||||
- `diameter`: 瓶体直径 (mm)
|
|
||||||
- `height`: 瓶体高度 (mm)
|
|
||||||
- `max_volume`: 最大容积(**μL**,500mL = 500000)
|
|
||||||
- `barcode`: 条形码(可选)
|
|
||||||
|
|
||||||
### BottleCarrier
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pylabrobot.resources import ResourceHolder
|
|
||||||
from pylabrobot.resources.carrier import create_ordered_items_2d
|
|
||||||
from unilabos.resources.itemized_carrier import BottleCarrier
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="My_6SlotCarrier", category=["bottle_carriers"], description="六槽位载架")
|
|
||||||
def My_6SlotCarrier(name: str) -> BottleCarrier:
|
|
||||||
sites = create_ordered_items_2d(
|
|
||||||
klass=ResourceHolder,
|
|
||||||
num_items_x=3, num_items_y=2,
|
|
||||||
dx=10.0, dy=10.0, dz=5.0,
|
|
||||||
item_dx=42.0, item_dy=35.0,
|
|
||||||
size_x=20.0, size_y=20.0, size_z=50.0,
|
|
||||||
)
|
|
||||||
# 子 site 用 name 作为前缀
|
|
||||||
for k, v in sites.items():
|
|
||||||
v.name = f"{name}_{v.name}"
|
|
||||||
|
|
||||||
carrier = BottleCarrier(
|
|
||||||
name=name, size_x=146.0, size_y=80.0, size_z=55.0,
|
|
||||||
sites=sites, model="My_6SlotCarrier",
|
|
||||||
)
|
|
||||||
carrier.num_items_x = 3
|
|
||||||
carrier.num_items_y = 2
|
|
||||||
carrier.num_items_z = 1
|
|
||||||
|
|
||||||
# 放置 Bottle 时用 name 作为前缀
|
|
||||||
ordering = ["A1", "B1", "A2", "B2", "A3", "B3"]
|
|
||||||
for i in range(6):
|
|
||||||
carrier[i] = My_Reagent_Bottle(f"{name}_vial_{ordering[i]}")
|
|
||||||
return carrier
|
|
||||||
```
|
|
||||||
|
|
||||||
### WareHouse / Deck 放置位
|
|
||||||
|
|
||||||
WareHouse 和 Site 本质上是同一概念:都是定义一组固定放置位(slot),根据物理尺寸自行批量计算偏移坐标。WareHouse 只是多嵌套了一层 Deck 而已。推荐开发者直接根据实物测量数据计算各 slot 偏移量。
|
|
||||||
|
|
||||||
#### WareHouse(使用 warehouse_factory)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.resources.warehouse import warehouse_factory
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="my_warehouse_4x4", category=["warehouse"], description="4x4 堆栈仓库")
|
|
||||||
def my_warehouse_4x4(name: str) -> "WareHouse":
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0, # 第一个 slot 的起始偏移
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0, # slot 间距
|
|
||||||
resource_size_x=127.0, resource_size_y=85.0, resource_size_z=100.0, # slot 尺寸
|
|
||||||
model="my_warehouse_4x4",
|
|
||||||
col_offset=0, # 列标签起始偏移(0 → A01, 4 → A05)
|
|
||||||
layout="row-major", # "row-major" 行优先 / "col-major" 列优先 / "vertical-col-major" 竖向
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`warehouse_factory` 参数说明:
|
|
||||||
- `dx/dy/dz`:第一个 slot 相对 WareHouse 原点的偏移(mm)
|
|
||||||
- `item_dx/item_dy/item_dz`:相邻 slot 间距(mm),需根据实际物理间距测量
|
|
||||||
- `resource_size_x/y/z`:每个 slot 的可放置区域尺寸
|
|
||||||
- `layout`:影响 slot 标签和坐标映射
|
|
||||||
- `"row-major"`:A01,A02,...,B01,B02,...(行优先,适合横向排列)
|
|
||||||
- `"col-major"`:A01,B01,...,A02,B02,...(列优先)
|
|
||||||
- `"vertical-col-major"`:竖向排列,y 坐标反向
|
|
||||||
|
|
||||||
#### Deck 组装 WareHouse
|
|
||||||
|
|
||||||
Deck 通过 `setup()` 将多个 WareHouse 放置到指定坐标:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pylabrobot.resources import Deck, Coordinate
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="MyStation_Deck", category=["deck"], description="我的工作站 Deck")
|
|
||||||
class MyStation_Deck(Deck):
|
|
||||||
def __init__(self, name="MyStation_Deck", size_x=2700.0, size_y=1080.0, size_z=1500.0,
|
|
||||||
category="deck", setup=False, **kwargs) -> None:
|
|
||||||
super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z)
|
|
||||||
if setup:
|
|
||||||
self.setup()
|
|
||||||
|
|
||||||
def setup(self) -> None:
|
|
||||||
self.warehouses = {
|
|
||||||
"堆栈1左": my_warehouse_4x4("堆栈1左"),
|
|
||||||
"堆栈1右": my_warehouse_4x4("堆栈1右"),
|
|
||||||
}
|
|
||||||
self.warehouse_locations = {
|
|
||||||
"堆栈1左": Coordinate(-200.0, 400.0, 0.0), # 自行测量计算
|
|
||||||
"堆栈1右": Coordinate(2350.0, 400.0, 0.0),
|
|
||||||
}
|
|
||||||
for wh_name, wh in self.warehouses.items():
|
|
||||||
self.assign_child_resource(wh, location=self.warehouse_locations[wh_name])
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Site 模式(前端定向放置)
|
|
||||||
|
|
||||||
适用于有固定孔位/槽位的设备(如移液站 PRCXI 9300),Deck 通过 `sites` 列表定义前端展示的放置位,前端据此渲染可拖拽的孔位布局:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import collections
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from pylabrobot.resources import Deck, Resource, Coordinate
|
|
||||||
from unilabos.registry.decorators import resource
|
|
||||||
|
|
||||||
|
|
||||||
@resource(id="MyLabDeck", category=["deck"], description="带 Site 定向放置的 Deck")
|
|
||||||
class MyLabDeck(Deck):
|
|
||||||
# 根据设备台面实测批量计算各 slot 坐标偏移
|
|
||||||
_DEFAULT_SITE_POSITIONS = [
|
|
||||||
(0, 0, 0), (138, 0, 0), (276, 0, 0), (414, 0, 0), # T1-T4
|
|
||||||
(0, 96, 0), (138, 96, 0), (276, 96, 0), (414, 96, 0), # T5-T8
|
|
||||||
]
|
|
||||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86.0, "depth": 0}
|
|
||||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "tube_rack", "adaptor"]
|
|
||||||
|
|
||||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float,
|
|
||||||
sites: Optional[List[Dict[str, Any]]] = None, **kwargs):
|
|
||||||
super().__init__(size_x, size_y, size_z, name)
|
|
||||||
if sites is not None:
|
|
||||||
self.sites = [dict(s) for s in sites]
|
|
||||||
else:
|
|
||||||
self.sites = []
|
|
||||||
for i, (x, y, z) in enumerate(self._DEFAULT_SITE_POSITIONS):
|
|
||||||
self.sites.append({
|
|
||||||
"label": f"T{i + 1}", # 前端显示的槽位标签
|
|
||||||
"visible": True, # 是否在前端可见
|
|
||||||
"position": {"x": x, "y": y, "z": z}, # 槽位物理坐标
|
|
||||||
"size": dict(self._DEFAULT_SITE_SIZE), # 槽位尺寸
|
|
||||||
"content_type": list(self._DEFAULT_CONTENT_TYPE), # 允许放入的物料类型
|
|
||||||
})
|
|
||||||
self._ordering = collections.OrderedDict(
|
|
||||||
(site["label"], None) for site in self.sites
|
|
||||||
)
|
|
||||||
|
|
||||||
def assign_child_resource(self, resource: Resource,
|
|
||||||
location: Optional[Coordinate] = None,
|
|
||||||
reassign: bool = True,
|
|
||||||
spot: Optional[int] = None):
|
|
||||||
idx = spot
|
|
||||||
if spot is None:
|
|
||||||
for i, site in enumerate(self.sites):
|
|
||||||
if site.get("label") == resource.name:
|
|
||||||
idx = i
|
|
||||||
break
|
|
||||||
if idx is None:
|
|
||||||
for i in range(len(self.sites)):
|
|
||||||
if self._get_site_resource(i) is None:
|
|
||||||
idx = i
|
|
||||||
break
|
|
||||||
if idx is None:
|
|
||||||
raise ValueError(f"No available site for '{resource.name}'")
|
|
||||||
loc = Coordinate(**self.sites[idx]["position"])
|
|
||||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
data = super().serialize()
|
|
||||||
sites_out = []
|
|
||||||
for i, site in enumerate(self.sites):
|
|
||||||
occupied = self._get_site_resource(i)
|
|
||||||
sites_out.append({
|
|
||||||
"label": site["label"],
|
|
||||||
"visible": site.get("visible", True),
|
|
||||||
"occupied_by": occupied.name if occupied else None,
|
|
||||||
"position": site["position"],
|
|
||||||
"size": site["size"],
|
|
||||||
"content_type": site["content_type"],
|
|
||||||
})
|
|
||||||
data["sites"] = sites_out
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
**Site 字段说明:**
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `label` | str | 槽位标签(如 `"T1"`),前端显示名称,也用于匹配 resource.name |
|
|
||||||
| `visible` | bool | 是否在前端可见 |
|
|
||||||
| `position` | dict | 物理坐标 `{x, y, z}`(mm),需自行测量计算偏移 |
|
|
||||||
| `size` | dict | 槽位尺寸 `{width, height, depth}`(mm) |
|
|
||||||
| `content_type` | list | 允许放入的物料类型,如 `["plate", "tip_rack", "tube_rack", "adaptor"]` |
|
|
||||||
|
|
||||||
**参考实现:** `unilabos/devices/liquid_handling/prcxi/prcxi.py` 中的 `PRCXI9300Deck`(4x4 共 16 个 site)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 文件位置
|
|
||||||
|
|
||||||
```
|
|
||||||
unilabos/resources/
|
|
||||||
├── <project>/ # 按项目分组
|
|
||||||
│ ├── bottles.py # Bottle 工厂函数
|
|
||||||
│ ├── bottle_carriers.py # Carrier 工厂函数
|
|
||||||
│ ├── warehouses.py # WareHouse 工厂函数
|
|
||||||
│ └── decks.py # Deck 类定义
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 资源可导入
|
|
||||||
python -c "from unilabos.resources.my_project.bottles import My_Reagent_Bottle; print(My_Reagent_Bottle('test'))"
|
|
||||||
|
|
||||||
# 启动测试(AST 自动扫描)
|
|
||||||
unilab -g <graph>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
仅在以下情况仍需 YAML:第三方库资源(如 pylabrobot 内置资源,无 `@resource` 装饰器)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键路径
|
|
||||||
|
|
||||||
| 内容 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
|
||||||
| WareHouse 基类 + 工厂 | `unilabos/resources/warehouse.py` |
|
|
||||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
|
||||||
| 装饰器定义 | `unilabos/registry/decorators.py` |
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# 资源高级参考
|
|
||||||
|
|
||||||
本文件是 SKILL.md 的补充,包含类继承体系、序列化/反序列化、Bioyond 物料同步、非瓶类资源和仓库工厂模式。Agent 在需要实现这些功能时按需阅读。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 类继承体系
|
|
||||||
|
|
||||||
```
|
|
||||||
PyLabRobot
|
|
||||||
├── Resource (PLR 基类)
|
|
||||||
│ ├── Well
|
|
||||||
│ │ └── Bottle (unilabos) → 瓶/小瓶/烧杯/反应器
|
|
||||||
│ ├── Deck
|
|
||||||
│ │ └── 自定义 Deck 类 (unilabos) → 工作站台面
|
|
||||||
│ ├── ResourceHolder → 槽位占位符
|
|
||||||
│ └── Container
|
|
||||||
│ └── Battery (unilabos) → 组装好的电池
|
|
||||||
│
|
|
||||||
├── ItemizedCarrier (unilabos, 继承 Resource)
|
|
||||||
│ ├── BottleCarrier (unilabos) → 瓶载架
|
|
||||||
│ └── WareHouse (unilabos) → 堆栈仓库
|
|
||||||
│
|
|
||||||
├── ItemizedResource (PLR)
|
|
||||||
│ └── MagazineHolder (unilabos) → 子弹夹载架
|
|
||||||
│
|
|
||||||
└── ResourceStack (PLR)
|
|
||||||
└── Magazine (unilabos) → 子弹夹洞位
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bottle 类细节
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Bottle(Well):
|
|
||||||
def __init__(self, name, diameter, height, max_volume,
|
|
||||||
size_x=0.0, size_y=0.0, size_z=0.0,
|
|
||||||
barcode=None, category="container", model=None, **kwargs):
|
|
||||||
super().__init__(
|
|
||||||
name=name,
|
|
||||||
size_x=diameter, # PLR 用 diameter 作为 size_x/size_y
|
|
||||||
size_y=diameter,
|
|
||||||
size_z=height, # PLR 用 height 作为 size_z
|
|
||||||
max_volume=max_volume,
|
|
||||||
category=category,
|
|
||||||
model=model,
|
|
||||||
bottom_type="flat",
|
|
||||||
cross_section_type="circle"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
注意 `size_x = size_y = diameter`,`size_z = height`。
|
|
||||||
|
|
||||||
### ItemizedCarrier 核心方法
|
|
||||||
|
|
||||||
| 方法 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `__getitem__(identifier)` | 通过索引或 Excel 标识(如 `"A01"`)访问槽位 |
|
|
||||||
| `__setitem__(identifier, resource)` | 向槽位放入资源 |
|
|
||||||
| `get_child_identifier(child)` | 获取子资源的标识符 |
|
|
||||||
| `capacity` | 总槽位数 |
|
|
||||||
| `sites` | 所有槽位字典 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 序列化与反序列化
|
|
||||||
|
|
||||||
### PLR ↔ UniLab 转换
|
|
||||||
|
|
||||||
| 函数 | 位置 | 方向 |
|
|
||||||
|------|------|------|
|
|
||||||
| `ResourceTreeSet.from_plr_resources(resources)` | `resource_tracker.py` | PLR → UniLab |
|
|
||||||
| `ResourceTreeSet.to_plr_resources()` | `resource_tracker.py` | UniLab → PLR |
|
|
||||||
|
|
||||||
### `from_plr_resources` 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
PLR Resource
|
|
||||||
↓ build_uuid_mapping (递归生成 UUID)
|
|
||||||
↓ resource.serialize() → dict
|
|
||||||
↓ resource.serialize_all_state() → states
|
|
||||||
↓ resource_plr_inner (递归构建 ResourceDictInstance)
|
|
||||||
ResourceTreeSet
|
|
||||||
```
|
|
||||||
|
|
||||||
关键:每个 PLR 资源通过 `unilabos_uuid` 属性携带 UUID,`unilabos_extra` 携带扩展数据(如 `class` 名)。
|
|
||||||
|
|
||||||
### `to_plr_resources` 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
ResourceTreeSet
|
|
||||||
↓ collect_node_data (收集 UUID、状态、扩展数据)
|
|
||||||
↓ node_to_plr_dict (转为 PLR 字典格式)
|
|
||||||
↓ find_subclass(type_name, PLRResource) (查找 PLR 子类)
|
|
||||||
↓ sub_cls.deserialize(plr_dict) (反序列化)
|
|
||||||
↓ loop_set_uuid, loop_set_extra (递归设置 UUID 和扩展)
|
|
||||||
PLR Resource
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bottle 序列化
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Bottle(Well):
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
data = super().serialize()
|
|
||||||
return {**data, "diameter": self.diameter, "height": self.height}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def deserialize(cls, data: dict, allow_marshal=False):
|
|
||||||
barcode_data = data.pop("barcode", None)
|
|
||||||
instance = super().deserialize(data, allow_marshal=allow_marshal)
|
|
||||||
if barcode_data and isinstance(barcode_data, str):
|
|
||||||
instance.barcode = barcode_data
|
|
||||||
return instance
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Bioyond 物料同步
|
|
||||||
|
|
||||||
### 双向转换函数
|
|
||||||
|
|
||||||
| 函数 | 位置 | 方向 |
|
|
||||||
|------|------|------|
|
|
||||||
| `resource_bioyond_to_plr(materials, type_mapping, deck)` | `graphio.py` | Bioyond → PLR |
|
|
||||||
| `resource_plr_to_bioyond(resources, type_mapping, warehouse_mapping)` | `graphio.py` | PLR → Bioyond |
|
|
||||||
|
|
||||||
### `resource_bioyond_to_plr` 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
Bioyond 物料列表
|
|
||||||
↓ reverse_type_mapping: {typeName → (model, UUID)}
|
|
||||||
↓ 对每个物料:
|
|
||||||
typeName → 查映射 → model (如 "BIOYOND_PolymerStation_Reactor")
|
|
||||||
initialize_resource({"name": unique_name, "class": model})
|
|
||||||
↓ 设置 unilabos_extra (material_bioyond_id, material_bioyond_name 等)
|
|
||||||
↓ 处理 detail (子物料/坐标)
|
|
||||||
↓ 按 locationName 放入 deck.warehouses 对应槽位
|
|
||||||
PLR 资源列表
|
|
||||||
```
|
|
||||||
|
|
||||||
### `resource_plr_to_bioyond` 流程
|
|
||||||
|
|
||||||
```
|
|
||||||
PLR 资源列表
|
|
||||||
↓ 遍历每个资源:
|
|
||||||
载架(capacity > 1): 生成 details 子物料 + 坐标
|
|
||||||
单瓶: 直接映射
|
|
||||||
↓ type_mapping 查找 typeId
|
|
||||||
↓ warehouse_mapping 查找位置 UUID
|
|
||||||
↓ 组装 Bioyond 格式 (name, typeName, typeId, quantity, Parameters, locations)
|
|
||||||
Bioyond 物料列表
|
|
||||||
```
|
|
||||||
|
|
||||||
### BioyondResourceSynchronizer
|
|
||||||
|
|
||||||
工作站通过 `ResourceSynchronizer` 自动同步物料:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class BioyondResourceSynchronizer(ResourceSynchronizer):
|
|
||||||
def sync_from_external(self) -> bool:
|
|
||||||
all_data = []
|
|
||||||
all_data.extend(api_client.stock_material('{"typeMode": 0}')) # 耗材
|
|
||||||
all_data.extend(api_client.stock_material('{"typeMode": 1}')) # 样品
|
|
||||||
all_data.extend(api_client.stock_material('{"typeMode": 2}')) # 试剂
|
|
||||||
unilab_resources = resource_bioyond_to_plr(
|
|
||||||
all_data,
|
|
||||||
type_mapping=self.workstation.bioyond_config["material_type_mappings"],
|
|
||||||
deck=self.workstation.deck
|
|
||||||
)
|
|
||||||
# 更新 deck 上的资源
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 非瓶类资源
|
|
||||||
|
|
||||||
### ElectrodeSheet(极片)
|
|
||||||
|
|
||||||
路径:`unilabos/resources/battery/electrode_sheet.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ElectrodeSheet(ResourcePLR):
|
|
||||||
"""片状材料(极片、隔膜、弹片、垫片等)"""
|
|
||||||
_unilabos_state = {
|
|
||||||
"diameter": 0.0,
|
|
||||||
"thickness": 0.0,
|
|
||||||
"mass": 0.0,
|
|
||||||
"material_type": "",
|
|
||||||
"color": "",
|
|
||||||
"info": "",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
工厂函数:`PositiveCan`, `PositiveElectrode`, `NegativeCan`, `NegativeElectrode`, `SpringWasher`, `FlatWasher`, `AluminumFoil`
|
|
||||||
|
|
||||||
### Battery(电池)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Battery(Container):
|
|
||||||
"""组装好的电池"""
|
|
||||||
_unilabos_state = {
|
|
||||||
"color": "",
|
|
||||||
"electrolyte_name": "",
|
|
||||||
"open_circuit_voltage": 0.0,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Magazine / MagazineHolder(子弹夹)
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Magazine(ResourceStack):
|
|
||||||
"""子弹夹洞位,可堆叠 ElectrodeSheet"""
|
|
||||||
# direction, max_sheets
|
|
||||||
|
|
||||||
class MagazineHolder(ItemizedResource):
|
|
||||||
"""多洞位子弹夹"""
|
|
||||||
# hole_diameter, hole_depth, max_sheets_per_hole
|
|
||||||
```
|
|
||||||
|
|
||||||
工厂函数 `magazine_factory()` 用 `create_homogeneous_resources` 生成洞位,可选预填 `ElectrodeSheet` 或 `Battery`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 仓库工厂模式参考
|
|
||||||
|
|
||||||
### 实际 warehouse 工厂函数示例
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 行优先 4x4 仓库
|
|
||||||
def bioyond_warehouse_1x4x4(name: str) -> WareHouse:
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0,
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
|
||||||
layout="row-major", # A01,A02,A03,A04, B01,...
|
|
||||||
)
|
|
||||||
|
|
||||||
# 右侧 4x4 仓库(列名偏移)
|
|
||||||
def bioyond_warehouse_1x4x4_right(name: str) -> WareHouse:
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=4, num_items_y=4, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0,
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
|
||||||
col_offset=4, # A05,A06,A07,A08
|
|
||||||
layout="row-major",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 竖向仓库(站内试剂存放)
|
|
||||||
def bioyond_warehouse_reagent_storage(name: str) -> WareHouse:
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=1, num_items_y=2, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0,
|
|
||||||
item_dx=147.0, item_dy=106.0, item_dz=130.0,
|
|
||||||
layout="vertical-col-major",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 行偏移(F 行开始)
|
|
||||||
def bioyond_warehouse_5x3x1(name: str, row_offset: int = 0) -> WareHouse:
|
|
||||||
return warehouse_factory(
|
|
||||||
name=name,
|
|
||||||
num_items_x=3, num_items_y=5, num_items_z=1,
|
|
||||||
dx=10.0, dy=10.0, dz=10.0,
|
|
||||||
item_dx=159.0, item_dy=183.0, item_dz=130.0,
|
|
||||||
row_offset=row_offset, # 0→A行起,5→F行起
|
|
||||||
layout="row-major",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### layout 类型说明
|
|
||||||
|
|
||||||
| layout | 命名顺序 | 适用场景 |
|
|
||||||
|--------|---------|---------|
|
|
||||||
| `col-major` (默认) | A01,B01,C01,D01, A02,B02,... | 列优先,标准堆栈 |
|
|
||||||
| `row-major` | A01,A02,A03,A04, B01,B02,... | 行优先,Bioyond 前端展示 |
|
|
||||||
| `vertical-col-major` | 竖向排列,标签从底部开始 | 竖向仓库(试剂存放、测密度) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 关键路径
|
|
||||||
|
|
||||||
| 内容 | 路径 |
|
|
||||||
|------|------|
|
|
||||||
| Bottle/Carrier 基类 | `unilabos/resources/itemized_carrier.py` |
|
|
||||||
| WareHouse 类 + 工厂 | `unilabos/resources/warehouse.py` |
|
|
||||||
| ResourceTreeSet 转换 | `unilabos/resources/resource_tracker.py` |
|
|
||||||
| Bioyond 物料转换 | `unilabos/resources/graphio.py` |
|
|
||||||
| Bioyond 仓库定义 | `unilabos/resources/bioyond/warehouses.py` |
|
|
||||||
| 电池资源 | `unilabos/resources/battery/` |
|
|
||||||
| PLR 注册 | `unilabos/resources/plr_additional_res_reg.py` |
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
---
|
|
||||||
name: add-workstation
|
|
||||||
description: Guide for adding new workstations to Uni-Lab-OS (接入新工作站). Uses @device decorator + AST auto-scanning. Walks through workstation type, sub-device composition, driver creation, deck setup, and graph file. Use when the user wants to add a workstation, create a workstation driver, configure a station with sub-devices, or mentions 工作站/工站/station/workstation.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Uni-Lab-OS 工作站接入指南
|
|
||||||
|
|
||||||
工作站(workstation)是组合多个子设备的大型设备,拥有独立的物料管理系统和工作流引擎。使用 `@device` 装饰器注册,AST 自动扫描生成注册表。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 工作站类型
|
|
||||||
|
|
||||||
| 类型 | 基类 | 适用场景 |
|
|
||||||
| ------------------- | ----------------- | ---------------------------------- |
|
|
||||||
| **Protocol 工作站** | `ProtocolNode` | 标准化学操作协议(泵转移、过滤等) |
|
|
||||||
| **外部系统工作站** | `WorkstationBase` | 与外部 LIMS/MES 对接 |
|
|
||||||
| **硬件控制工作站** | `WorkstationBase` | 直接控制 PLC/硬件 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## @device 装饰器(工作站)
|
|
||||||
|
|
||||||
工作站也使用 `@device` 装饰器注册,参数与普通设备一致:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@device(
|
|
||||||
id="my_workstation", # 注册表唯一标识(必填)
|
|
||||||
category=["workstation"], # 分类标签
|
|
||||||
description="我的工作站",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
如果一个工作站类支持多个具体变体,可使用 `ids` / `id_meta`,与设备的用法相同(参见 add-device SKILL)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 工作站驱动模板
|
|
||||||
|
|
||||||
### 模板 A:基于外部系统的工作站
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from pylabrobot.resources import Deck
|
|
||||||
|
|
||||||
from unilabos.registry.decorators import device, topic_config, not_action
|
|
||||||
from unilabos.devices.workstation.workstation_base import WorkstationBase
|
|
||||||
|
|
||||||
try:
|
|
||||||
from unilabos.ros.nodes.presets.workstation import ROS2WorkstationNode
|
|
||||||
except ImportError:
|
|
||||||
ROS2WorkstationNode = None
|
|
||||||
|
|
||||||
|
|
||||||
@device(id="my_workstation", category=["workstation"], description="我的工作站")
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
_ros_node: "ROS2WorkstationNode"
|
|
||||||
|
|
||||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
|
||||||
super().__init__(deck=deck, **kwargs)
|
|
||||||
self.config = config or {}
|
|
||||||
self.logger = logging.getLogger("MyWorkstation")
|
|
||||||
self.api_host = self.config.get("api_host", "")
|
|
||||||
self._status = "Idle"
|
|
||||||
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node: "ROS2WorkstationNode"):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
self._ros_node = ros_node
|
|
||||||
|
|
||||||
async def scheduler_start(self, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""注册为工作站动作"""
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
async def create_order(self, json_str: str, **kwargs) -> Dict[str, Any]:
|
|
||||||
"""注册为工作站动作"""
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def workflow_sequence(self) -> str:
|
|
||||||
return "[]"
|
|
||||||
|
|
||||||
@property
|
|
||||||
@topic_config()
|
|
||||||
def material_info(self) -> str:
|
|
||||||
return "{}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 模板 B:Protocol 工作站
|
|
||||||
|
|
||||||
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_base import ProtocolNode
|
|
||||||
```
|
|
||||||
|
|
||||||
在图文件中配置 `protocol_type` 即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 子设备访问(sub_devices)
|
|
||||||
|
|
||||||
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中(key 为设备 id,value 为 `ROS2DeviceNode` 实例)。工站的驱动类可以直接获取子设备实例来调用其方法:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 在工站驱动类的方法中访问子设备
|
|
||||||
sub = self._ros_node.sub_devices["pump_1"]
|
|
||||||
|
|
||||||
# .driver_instance — 子设备的驱动实例(即设备 Python 类的实例)
|
|
||||||
sub.driver_instance.some_method(arg1, arg2)
|
|
||||||
|
|
||||||
# .ros_node_instance — 子设备的 ROS2 节点实例
|
|
||||||
sub.ros_node_instance._action_value_mappings # 查看子设备支持的 action
|
|
||||||
```
|
|
||||||
|
|
||||||
**常见用法**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
def my_protocol(self, **kwargs):
|
|
||||||
# 获取子设备驱动实例
|
|
||||||
pump = self._ros_node.sub_devices["pump_1"].driver_instance
|
|
||||||
heater = self._ros_node.sub_devices["heater_1"].driver_instance
|
|
||||||
|
|
||||||
# 直接调用子设备方法
|
|
||||||
pump.aspirate(volume=100)
|
|
||||||
heater.set_temperature(80)
|
|
||||||
```
|
|
||||||
|
|
||||||
> 参考实现:`unilabos/devices/workstation/bioyond_studio/reaction_station/reaction_station.py` 中通过 `self._ros_node.sub_devices.get(reactor_id)` 获取子反应器实例并更新数据。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 硬件通信接口(hardware_interface)
|
|
||||||
|
|
||||||
硬件控制型工作站通常需要通过串口(Serial)、Modbus 等通信协议控制多个子设备。Uni-Lab-OS 通过 **通信设备代理** 机制实现端口共享:一个串口只创建一个 `serial` 节点,多个子设备共享这个通信实例。
|
|
||||||
|
|
||||||
### 工作原理
|
|
||||||
|
|
||||||
`ROS2WorkstationNode` 初始化时分两轮遍历子设备(`workstation.py`):
|
|
||||||
|
|
||||||
**第一轮 — 初始化所有子设备**:按 `children` 顺序调用 `initialize_device()`,通信设备(`serial_` / `io_` 开头的 id)优先完成初始化,创建 `serial.Serial()` 实例。其他子设备此时 `self.hardware_interface = "serial_pump"`(字符串)。
|
|
||||||
|
|
||||||
**第二轮 — 代理替换**:遍历所有已初始化的子设备,读取子设备的 `_hardware_interface` 配置:
|
|
||||||
|
|
||||||
```
|
|
||||||
hardware_interface = d.ros_node_instance._hardware_interface
|
|
||||||
# → {"name": "hardware_interface", "read": "send_command", "write": "send_command"}
|
|
||||||
```
|
|
||||||
|
|
||||||
1. 取 `name` 字段对应的属性值:`name_value = getattr(driver, hardware_interface["name"])`
|
|
||||||
- 如果 `name_value` 是字符串且该字符串是某个子设备的 id → 触发代理替换
|
|
||||||
2. 从通信设备获取真正的 `read`/`write` 方法
|
|
||||||
3. 用 `setattr(driver, read_method, _read)` 将通信设备的方法绑定到子设备上
|
|
||||||
|
|
||||||
因此:
|
|
||||||
|
|
||||||
- **通信设备 id 必须与子设备 config 中填的字符串完全一致**(如 `"serial_pump"`)
|
|
||||||
- **通信设备 id 必须以 `serial_` 或 `io_` 开头**(否则第一轮不会被识别为通信设备)
|
|
||||||
- **通信设备必须在 `children` 列表中排在最前面**,确保先初始化
|
|
||||||
|
|
||||||
### HardwareInterface 参数说明
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import HardwareInterface
|
|
||||||
|
|
||||||
HardwareInterface(
|
|
||||||
name="hardware_interface", # __init__ 中接收通信实例的属性名
|
|
||||||
read="send_command", # 通信设备上暴露的读方法名
|
|
||||||
write="send_command", # 通信设备上暴露的写方法名
|
|
||||||
extra_info=["list_ports"], # 可选:额外暴露的方法
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**`name` 字段的含义**:对应设备类 `__init__` 中,用于保存通信实例的**属性名**。系统据此知道要替换哪个属性。大部分设备直接用 `"hardware_interface"`,也可以自定义(如 `"io_device_port"`)。
|
|
||||||
|
|
||||||
### 示例 1:泵(name="hardware_interface")
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device, HardwareInterface
|
|
||||||
|
|
||||||
@device(
|
|
||||||
id="my_pump",
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
hardware_interface=HardwareInterface(
|
|
||||||
name="hardware_interface",
|
|
||||||
read="send_command",
|
|
||||||
write="send_command",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class MyPump:
|
|
||||||
def __init__(self, port=None, address="1", **kwargs):
|
|
||||||
# name="hardware_interface" → 系统替换 self.hardware_interface
|
|
||||||
self.hardware_interface = port # 初始为字符串 "serial_pump",启动后被替换为 Serial 实例
|
|
||||||
self.address = address
|
|
||||||
|
|
||||||
def send_command(self, command: str):
|
|
||||||
full_command = f"/{self.address}{command}\r\n"
|
|
||||||
self.hardware_interface.write(bytearray(full_command, "ascii"))
|
|
||||||
return self.hardware_interface.read_until(b"\n")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 示例 2:电磁阀(name="io_device_port",自定义属性名)
|
|
||||||
|
|
||||||
```python
|
|
||||||
@device(
|
|
||||||
id="solenoid_valve",
|
|
||||||
category=["pump_and_valve"],
|
|
||||||
hardware_interface=HardwareInterface(
|
|
||||||
name="io_device_port", # 自定义属性名 → 系统替换 self.io_device_port
|
|
||||||
read="read_io_coil",
|
|
||||||
write="write_io_coil",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
class SolenoidValve:
|
|
||||||
def __init__(self, io_device_port: str = None, **kwargs):
|
|
||||||
# name="io_device_port" → 图文件 config 中用 "io_device_port": "io_board_1"
|
|
||||||
self.io_device_port = io_device_port # 初始为字符串,系统替换为 Modbus 实例
|
|
||||||
```
|
|
||||||
|
|
||||||
### Serial 通信设备(class="serial")
|
|
||||||
|
|
||||||
`serial` 是 Uni-Lab-OS 内置的通信代理设备,代码位于 `unilabos/ros/nodes/presets/serial_node.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from serial import Serial, SerialException
|
|
||||||
from threading import Lock
|
|
||||||
|
|
||||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
|
||||||
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, **kwargs):
|
|
||||||
self.port = port
|
|
||||||
self.baudrate = baudrate
|
|
||||||
self._hardware_interface = {
|
|
||||||
"name": "hardware_interface",
|
|
||||||
"write": "send_command",
|
|
||||||
"read": "read_data",
|
|
||||||
}
|
|
||||||
self._query_lock = Lock()
|
|
||||||
|
|
||||||
self.hardware_interface = Serial(baudrate=baudrate, port=port)
|
|
||||||
|
|
||||||
BaseROS2DeviceNode.__init__(
|
|
||||||
self, driver_instance=self, registry_name=registry_name,
|
|
||||||
device_id=device_id, status_types={}, action_value_mappings={},
|
|
||||||
hardware_interface=self._hardware_interface, print_publish=False,
|
|
||||||
)
|
|
||||||
self.create_service(SerialCommand, "serialwrite", self.handle_serial_request)
|
|
||||||
|
|
||||||
def send_command(self, command: str):
|
|
||||||
with self._query_lock:
|
|
||||||
self.hardware_interface.write(bytearray(f"{command}\n", "ascii"))
|
|
||||||
return self.hardware_interface.read_until(b"\n").decode()
|
|
||||||
|
|
||||||
def read_data(self):
|
|
||||||
with self._query_lock:
|
|
||||||
return self.hardware_interface.read_until(b"\n").decode()
|
|
||||||
```
|
|
||||||
|
|
||||||
在图文件中使用 `"class": "serial"` 即可创建串口代理:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "serial_pump",
|
|
||||||
"class": "serial",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "COM7", "baudrate": 9600 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 图文件配置
|
|
||||||
|
|
||||||
**通信设备必须在 `children` 列表中排在最前面**,确保先于其他子设备初始化:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "my_station",
|
|
||||||
"class": "workstation",
|
|
||||||
"children": ["serial_pump", "pump_1", "pump_2"],
|
|
||||||
"config": { "protocol_type": ["PumpTransferProtocol"] }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "serial_pump",
|
|
||||||
"class": "serial",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "COM7", "baudrate": 9600 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pump_1",
|
|
||||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "serial_pump", "address": "1", "max_volume": 25.0 }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pump_2",
|
|
||||||
"class": "syringe_pump_with_valve.runze.SY03B-T08",
|
|
||||||
"parent": "my_station",
|
|
||||||
"config": { "port": "serial_pump", "address": "2", "max_volume": 25.0 }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"source": "pump_1",
|
|
||||||
"target": "serial_pump",
|
|
||||||
"type": "communication",
|
|
||||||
"port": { "pump_1": "port", "serial_pump": "port" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"source": "pump_2",
|
|
||||||
"target": "serial_pump",
|
|
||||||
"type": "communication",
|
|
||||||
"port": { "pump_2": "port", "serial_pump": "port" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通信协议速查
|
|
||||||
|
|
||||||
| 协议 | config 参数 | 依赖包 | 通信设备 class |
|
|
||||||
| -------------------- | ------------------------------ | ---------- | -------------------------- |
|
|
||||||
| Serial (RS232/RS485) | `port`, `baudrate` | `pyserial` | `serial` |
|
|
||||||
| Modbus RTU | `port`, `baudrate`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
|
||||||
| Modbus TCP | `host`, `port`, `slave_id` | `pymodbus` | `device_comms/modbus_plc/` |
|
|
||||||
| TCP Socket | `host`, `port` | stdlib | 自定义 |
|
|
||||||
| HTTP API | `url`, `token` | `requests` | `device_comms/rpc.py` |
|
|
||||||
|
|
||||||
参考实现:`unilabos/test/experiments/Grignard_flow_batchreact_single_pumpvalve.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deck 与物料生命周期
|
|
||||||
|
|
||||||
### 1. Deck 入参与两种初始化模式
|
|
||||||
|
|
||||||
系统根据设备节点 `config.deck` 的写法,自动反序列化 Deck 实例后传入 `__init__` 的 `deck` 参数。目前 `deck` 是固定字段名,只支持一个主 Deck。建议一个设备拥有一个台面,台面上抽象二级、三级子物料。
|
|
||||||
|
|
||||||
有两种初始化模式:
|
|
||||||
|
|
||||||
#### init 初始化(推荐)
|
|
||||||
|
|
||||||
`config.deck` 直接包含 `_resource_type` + `_resource_child_name`,系统先用 Deck 节点的 `config` 调用 Deck 类的 `__init__` 反序列化,再将实例传入设备的 `deck` 参数。子物料随 Deck 的 `children` 一起反序列化。
|
|
||||||
|
|
||||||
```json
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
|
|
||||||
"_resource_child_name": "PRCXI_Deck"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### deserialize 初始化
|
|
||||||
|
|
||||||
`config.deck` 用 `data` 包裹一层,系统走 `deserialize` 路径,可传入更多参数(如 `allow_marshal` 等):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"data": {
|
|
||||||
"_resource_child_name": "YB_Bioyond_Deck",
|
|
||||||
"_resource_type": "unilabos.resources.bioyond.decks:BIOYOND_YB_Deck"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
没有特殊需求时推荐 init 初始化。
|
|
||||||
|
|
||||||
#### config.deck 字段说明
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `_resource_type` | Deck 类的完整模块路径(`module:ClassName`) |
|
|
||||||
| `_resource_child_name` | 对应图文件中 Deck 节点的 `id`,建立父子关联 |
|
|
||||||
|
|
||||||
#### 设备 __init__ 接收
|
|
||||||
|
|
||||||
```python
|
|
||||||
def __init__(self, config=None, deck=None, protocol_type=None, **kwargs):
|
|
||||||
super().__init__(deck=deck, **kwargs)
|
|
||||||
# deck 已经是反序列化后的 Deck 实例
|
|
||||||
# → PRCXI9300Deck / BIOYOND_YB_Deck 等
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Deck 节点(图文件中)
|
|
||||||
|
|
||||||
Deck 节点作为设备的 `children` 之一,`parent` 指向设备 id:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "PRCXI_Deck",
|
|
||||||
"parent": "PRCXI",
|
|
||||||
"type": "deck",
|
|
||||||
"class": "",
|
|
||||||
"children": [],
|
|
||||||
"config": {
|
|
||||||
"type": "PRCXI9300Deck",
|
|
||||||
"size_x": 542, "size_y": 374, "size_z": 0,
|
|
||||||
"category": "deck",
|
|
||||||
"sites": [...]
|
|
||||||
},
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `config` 中的字段会传入 Deck 类的 `__init__`(因此 `__init__` 必须能接受所有 `serialize()` 输出的字段)
|
|
||||||
- `children` 初始为空时,由同步器或手动初始化填充
|
|
||||||
- `config.type` 填 Deck 类名
|
|
||||||
|
|
||||||
### 2. Deck 为空时自行初始化
|
|
||||||
|
|
||||||
如果 Deck 节点的 `children` 为空,工作站需在 `post_init` 或首次同步时自行初始化内容:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
if self.deck and not self.deck.children:
|
|
||||||
self._initialize_default_deck()
|
|
||||||
|
|
||||||
def _initialize_default_deck(self):
|
|
||||||
from my_labware import My_TipRack, My_Plate
|
|
||||||
self.deck.assign_child_resource(My_TipRack("T1"), spot=0)
|
|
||||||
self.deck.assign_child_resource(My_Plate("T2"), spot=1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 物料双向同步
|
|
||||||
|
|
||||||
当工作站对接外部系统(LIMS/MES)时,需要实现 `ResourceSynchronizer` 处理双向物料同步:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
|
||||||
|
|
||||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
|
||||||
def sync_from_external(self) -> bool:
|
|
||||||
"""从外部系统同步到 self.workstation.deck"""
|
|
||||||
external_data = self._query_external_materials()
|
|
||||||
# 以外部工站为准:根据外部数据反向创建 PLR 资源实例
|
|
||||||
for item in external_data:
|
|
||||||
cls = self._resolve_resource_class(item["type"])
|
|
||||||
resource = cls(name=item["name"], **item["params"])
|
|
||||||
self.workstation.deck.assign_child_resource(resource, spot=item["slot"])
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sync_to_external(self, resource) -> bool:
|
|
||||||
"""将 UniLab 侧物料变更同步到外部系统"""
|
|
||||||
# 以 UniLab 为准:将 PLR 资源转为外部格式并推送
|
|
||||||
external_format = self._convert_to_external(resource)
|
|
||||||
return self._push_to_external(external_format)
|
|
||||||
|
|
||||||
def handle_external_change(self, change_info) -> bool:
|
|
||||||
"""处理外部系统主动推送的变更"""
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
同步策略取决于业务场景:
|
|
||||||
|
|
||||||
- **以外部工站为准**:从外部 API 查询物料数据,反向创建对应的 PLR 资源实例放到 Deck 上
|
|
||||||
- **以 UniLab 为准**:UniLab 侧的物料变更通过 `sync_to_external` 推送到外部系统
|
|
||||||
|
|
||||||
在工作站 `post_init` 中初始化同步器:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@not_action
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
self.resource_synchronizer = MyResourceSynchronizer(self)
|
|
||||||
self.resource_synchronizer.sync_from_external()
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 序列化与持久化(serialize / serialize_state)
|
|
||||||
|
|
||||||
资源类需正确实现序列化,系统据此完成持久化和前端同步。
|
|
||||||
|
|
||||||
**`serialize()`** — 输出资源的结构信息(`config` 层),反序列化时作为 `__init__` 的入参回传。因此 **`__init__` 必须通过 `**kwargs`接受`serialize()` 输出的所有字段\*\*,即使当前不使用:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyDeck(Deck):
|
|
||||||
def __init__(self, name, size_x, size_y, size_z,
|
|
||||||
sites=None, # serialize() 输出的字段
|
|
||||||
rotation=None, # serialize() 输出的字段
|
|
||||||
barcode=None, # serialize() 输出的字段
|
|
||||||
**kwargs): # 兜底:接受所有未知的 serialize 字段
|
|
||||||
super().__init__(size_x, size_y, size_z, name)
|
|
||||||
# ...
|
|
||||||
|
|
||||||
def serialize(self) -> dict:
|
|
||||||
data = super().serialize()
|
|
||||||
data["sites"] = [...] # 自定义字段
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
**`serialize_state()`** — 输出资源的运行时状态(`data` 层),用于持久化可变信息。`data` 中的内容会被正确保存和恢复:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyPlate(Plate):
|
|
||||||
def __init__(self, name, size_x, size_y, size_z,
|
|
||||||
material_info=None, **kwargs):
|
|
||||||
super().__init__(name, size_x, size_y, size_z, **kwargs)
|
|
||||||
self._unilabos_state = {}
|
|
||||||
if material_info:
|
|
||||||
self._unilabos_state["Material"] = material_info
|
|
||||||
|
|
||||||
def serialize_state(self) -> Dict[str, Any]:
|
|
||||||
data = super().serialize_state()
|
|
||||||
data.update(self._unilabos_state)
|
|
||||||
return data
|
|
||||||
```
|
|
||||||
|
|
||||||
关键要点:
|
|
||||||
|
|
||||||
- `serialize()` 输出的所有字段都会作为 `config` 回传到 `__init__`,所以 `__init__` 必须能接受它们(显式声明或 `**kwargs`)
|
|
||||||
- `serialize_state()` 输出的 `data` 用于持久化运行时状态(如物料信息、液体量等)
|
|
||||||
- `_unilabos_state` 中只存可 JSON 序列化的基本类型(str, int, float, bool, list, dict, None)
|
|
||||||
|
|
||||||
### 5. 子物料自动同步
|
|
||||||
|
|
||||||
子物料(Bottle、Plate、TipRack 等)放到 Deck 上后,系统会自动将其同步到前端的 Deck 视图。只需保证资源类正确实现了 `serialize()` / `serialize_state()` 和反序列化即可。
|
|
||||||
|
|
||||||
### 6. 图文件配置(参考 prcxi_9320_slim.json)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"id": "my_station",
|
|
||||||
"type": "device",
|
|
||||||
"class": "my_workstation",
|
|
||||||
"config": {
|
|
||||||
"deck": {
|
|
||||||
"_resource_type": "unilabos.resources.my_module:MyDeck",
|
|
||||||
"_resource_child_name": "my_deck"
|
|
||||||
},
|
|
||||||
"host": "10.20.30.1",
|
|
||||||
"port": 9999
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "my_deck",
|
|
||||||
"parent": "my_station",
|
|
||||||
"type": "deck",
|
|
||||||
"class": "",
|
|
||||||
"children": [],
|
|
||||||
"config": {
|
|
||||||
"type": "MyLabDeck",
|
|
||||||
"size_x": 542,
|
|
||||||
"size_y": 374,
|
|
||||||
"size_z": 0,
|
|
||||||
"category": "deck",
|
|
||||||
"sites": [
|
|
||||||
{
|
|
||||||
"label": "T1",
|
|
||||||
"visible": true,
|
|
||||||
"occupied_by": null,
|
|
||||||
"position": { "x": 0, "y": 0, "z": 0 },
|
|
||||||
"size": { "width": 128.0, "height": 86, "depth": 0 },
|
|
||||||
"content_type": ["plate", "tip_rack", "tube_rack", "adaptor"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"data": {}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"edges": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Deck 节点要点:
|
|
||||||
|
|
||||||
- `config.type` 填 Deck 类名(如 `"PRCXI9300Deck"`)
|
|
||||||
- `config.sites` 完整列出所有 site(从 Deck 类的 `serialize()` 输出获取)
|
|
||||||
- `children` 初始为空(由同步器或手动初始化填充)
|
|
||||||
- 设备节点 `config.deck._resource_type` 指向 Deck 类的完整模块路径
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 子设备
|
|
||||||
|
|
||||||
子设备按标准设备接入流程创建(参见 add-device SKILL),使用 `@device` 装饰器。
|
|
||||||
|
|
||||||
子设备约束:
|
|
||||||
|
|
||||||
- 图文件中 `parent` 指向工作站 ID
|
|
||||||
- 在工作站 `children` 数组中列出
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键规则
|
|
||||||
|
|
||||||
1. **`__init__` 必须接受 `deck` 和 `**kwargs`** — `WorkstationBase.**init**`需要`deck` 参数
|
|
||||||
2. **Deck 通过 `config.deck._resource_type` 反序列化传入** — 不要在 `__init__` 中手动创建 Deck
|
|
||||||
3. **Deck 为空时自行初始化内容** — 在 `post_init` 中检查并填充默认物料
|
|
||||||
4. **外部同步实现 `ResourceSynchronizer`** — `sync_from_external` / `sync_to_external`
|
|
||||||
5. **通过 `self._children` 访问子设备** — 不要自行维护子设备引用
|
|
||||||
6. **`post_init` 中启动后台服务** — 不要在 `__init__` 中启动网络连接
|
|
||||||
7. **异步方法使用 `await self._ros_node.sleep()`** — 禁止 `time.sleep()` 和 `asyncio.sleep()`
|
|
||||||
8. **使用 `@not_action` 标记非动作方法** — `post_init`, `initialize`, `cleanup`
|
|
||||||
9. **子物料保证正确 serialize/deserialize** — 系统自动同步到前端 Deck 视图
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 模块可导入
|
|
||||||
python -c "from unilabos.devices.workstation.<name>.<name> import <ClassName>"
|
|
||||||
|
|
||||||
# 启动测试(AST 自动扫描)
|
|
||||||
unilab -g <graph>.json
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 现有工作站参考
|
|
||||||
|
|
||||||
| 工作站 | 驱动类 | 类型 |
|
|
||||||
| -------------- | ----------------------------- | -------- |
|
|
||||||
| Protocol 通用 | `ProtocolNode` | Protocol |
|
|
||||||
| Bioyond 反应站 | `BioyondReactionStation` | 外部系统 |
|
|
||||||
| 纽扣电池组装 | `CoinCellAssemblyWorkstation` | 硬件控制 |
|
|
||||||
|
|
||||||
参考路径:`unilabos/devices/workstation/` 目录下各工作站实现。
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
# 工作站高级模式参考
|
|
||||||
|
|
||||||
本文件是 SKILL.md 的补充,包含外部系统集成、物料同步、配置结构等高级模式。
|
|
||||||
Agent 在需要实现这些功能时按需阅读。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 外部系统集成模式
|
|
||||||
|
|
||||||
### 1.1 RPC 客户端
|
|
||||||
|
|
||||||
与外部 LIMS/MES 系统通信的标准模式。继承 `BaseRequest`,所有接口统一用 POST。
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.device_comms.rpc import BaseRequest
|
|
||||||
|
|
||||||
|
|
||||||
class MySystemRPC(BaseRequest):
|
|
||||||
"""外部系统 RPC 客户端"""
|
|
||||||
|
|
||||||
def __init__(self, host: str, api_key: str):
|
|
||||||
super().__init__(host)
|
|
||||||
self.api_key = api_key
|
|
||||||
|
|
||||||
def _request(self, endpoint: str, data: dict = None) -> dict:
|
|
||||||
return self.post(
|
|
||||||
url=f"{self.host}/api/{endpoint}",
|
|
||||||
params={
|
|
||||||
"apiKey": self.api_key,
|
|
||||||
"requestTime": self.get_current_time_iso8601(),
|
|
||||||
"data": data or {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def query_status(self) -> dict:
|
|
||||||
return self._request("status/query")
|
|
||||||
|
|
||||||
def create_order(self, order_data: dict) -> dict:
|
|
||||||
return self._request("order/create", order_data)
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:`unilabos/devices/workstation/bioyond_studio/bioyond_rpc.py`(`BioyondV1RPC`)
|
|
||||||
|
|
||||||
### 1.2 HTTP 回调服务
|
|
||||||
|
|
||||||
接收外部系统报送的标准模式。使用 `WorkstationHTTPService`,在 `post_init` 中启动。
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_http_service import WorkstationHTTPService
|
|
||||||
|
|
||||||
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
def __init__(self, config=None, deck=None, **kwargs):
|
|
||||||
super().__init__(deck=deck, **kwargs)
|
|
||||||
self.config = config or {}
|
|
||||||
http_cfg = self.config.get("http_service_config", {})
|
|
||||||
self._http_service_config = {
|
|
||||||
"host": http_cfg.get("http_service_host", "127.0.0.1"),
|
|
||||||
"port": http_cfg.get("http_service_port", 8080),
|
|
||||||
}
|
|
||||||
self.http_service = None
|
|
||||||
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
self.http_service = WorkstationHTTPService(
|
|
||||||
workstation_instance=self,
|
|
||||||
host=self._http_service_config["host"],
|
|
||||||
port=self._http_service_config["port"],
|
|
||||||
)
|
|
||||||
self.http_service.start()
|
|
||||||
```
|
|
||||||
|
|
||||||
**HTTP 服务路由**(固定端点,由 `WorkstationHTTPHandler` 自动分发):
|
|
||||||
|
|
||||||
| 端点 | 调用的工作站方法 |
|
|
||||||
|------|-----------------|
|
|
||||||
| `/report/step_finish` | `process_step_finish_report(report_request)` |
|
|
||||||
| `/report/sample_finish` | `process_sample_finish_report(report_request)` |
|
|
||||||
| `/report/order_finish` | `process_order_finish_report(report_request, used_materials)` |
|
|
||||||
| `/report/material_change` | `process_material_change_report(report_data)` |
|
|
||||||
| `/report/error_handling` | `handle_external_error(error_data)` |
|
|
||||||
|
|
||||||
实现对应方法即可接收回调:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def process_step_finish_report(self, report_request) -> Dict[str, Any]:
|
|
||||||
"""处理步骤完成报告"""
|
|
||||||
step_name = report_request.data.get("stepName")
|
|
||||||
return {"success": True, "message": f"步骤 {step_name} 已处理"}
|
|
||||||
|
|
||||||
def process_order_finish_report(self, report_request, used_materials) -> Dict[str, Any]:
|
|
||||||
"""处理订单完成报告"""
|
|
||||||
order_code = report_request.data.get("orderCode")
|
|
||||||
return {"success": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:`unilabos/devices/workstation/workstation_http_service.py`
|
|
||||||
|
|
||||||
### 1.3 连接监控
|
|
||||||
|
|
||||||
独立线程周期性检测外部系统连接状态,状态变化时发布 ROS 事件。
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ConnectionMonitor:
|
|
||||||
def __init__(self, workstation, check_interval=30):
|
|
||||||
self.workstation = workstation
|
|
||||||
self.check_interval = check_interval
|
|
||||||
self._running = False
|
|
||||||
self._thread = None
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
self._running = True
|
|
||||||
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def _monitor_loop(self):
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
# 调用外部系统接口检测连接
|
|
||||||
self.workstation.hardware_interface.ping()
|
|
||||||
status = "online"
|
|
||||||
except Exception:
|
|
||||||
status = "offline"
|
|
||||||
time.sleep(self.check_interval)
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:`unilabos/devices/workstation/bioyond_studio/station.py`(`ConnectionMonitor`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Config 结构模式
|
|
||||||
|
|
||||||
工作站的 `config` 在图文件中定义,传入 `__init__`。以下是常见字段模式:
|
|
||||||
|
|
||||||
### 2.1 外部系统连接
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api_host": "http://192.168.1.100:8080",
|
|
||||||
"api_key": "YOUR_API_KEY"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 HTTP 回调服务
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"http_service_config": {
|
|
||||||
"http_service_host": "127.0.0.1",
|
|
||||||
"http_service_port": 8080
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 物料类型映射
|
|
||||||
|
|
||||||
将 PLR 资源类名映射到外部系统的物料类型(名称 + UUID)。用于双向物料转换。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"material_type_mappings": {
|
|
||||||
"PLR_ResourceClassName": ["外部系统显示名", "external-type-uuid"],
|
|
||||||
"BIOYOND_PolymerStation_Reactor": ["反应器", "3a14233b-902d-0d7b-..."]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 仓库映射
|
|
||||||
|
|
||||||
将仓库名映射到外部系统的仓库 UUID 和库位 UUID。用于入库/出库操作。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"warehouse_mapping": {
|
|
||||||
"仓库名": {
|
|
||||||
"uuid": "warehouse-uuid",
|
|
||||||
"site_uuids": {
|
|
||||||
"A01": "site-uuid-A01",
|
|
||||||
"A02": "site-uuid-A02"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.5 工作流映射
|
|
||||||
|
|
||||||
将内部工作流名映射到外部系统的工作流 ID。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"workflow_mappings": {
|
|
||||||
"internal_workflow_name": "external-workflow-uuid"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.6 物料默认参数
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"material_default_parameters": {
|
|
||||||
"NMP": {
|
|
||||||
"unit": "毫升",
|
|
||||||
"density": "1.03",
|
|
||||||
"densityUnit": "g/mL",
|
|
||||||
"description": "N-甲基吡咯烷酮"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 资源同步机制
|
|
||||||
|
|
||||||
### 3.1 ResourceSynchronizer
|
|
||||||
|
|
||||||
抽象基类,用于与外部物料系统双向同步。定义在 `workstation_base.py`。
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.devices.workstation.workstation_base import ResourceSynchronizer
|
|
||||||
|
|
||||||
|
|
||||||
class MyResourceSynchronizer(ResourceSynchronizer):
|
|
||||||
def __init__(self, workstation, api_client):
|
|
||||||
super().__init__(workstation)
|
|
||||||
self.api_client = api_client
|
|
||||||
|
|
||||||
def sync_from_external(self) -> bool:
|
|
||||||
"""从外部系统拉取物料到 deck"""
|
|
||||||
external_materials = self.api_client.list_materials()
|
|
||||||
for material in external_materials:
|
|
||||||
plr_resource = self._convert_to_plr(material)
|
|
||||||
self.workstation.deck.assign_child_resource(plr_resource, coordinate)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def sync_to_external(self, plr_resource) -> bool:
|
|
||||||
"""将 deck 中的物料变更推送到外部系统"""
|
|
||||||
external_data = self._convert_from_plr(plr_resource)
|
|
||||||
self.api_client.update_material(external_data)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def handle_external_change(self, change_info) -> bool:
|
|
||||||
"""处理外部系统推送的物料变更"""
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 update_resource — 上传资源树到云端
|
|
||||||
|
|
||||||
将 PLR Deck 序列化后通过 ROS 服务上传。典型使用场景:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 在 post_init 中上传初始 deck
|
|
||||||
from unilabos.ros.nodes.base_device_node import ROS2DeviceNode
|
|
||||||
|
|
||||||
ROS2DeviceNode.run_async_func(
|
|
||||||
self._ros_node.update_resource, True,
|
|
||||||
**{"resources": [self.deck]}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 在动作方法中更新特定资源
|
|
||||||
ROS2DeviceNode.run_async_func(
|
|
||||||
self._ros_node.update_resource, True,
|
|
||||||
**{"resources": [updated_plate]}
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 工作流序列管理
|
|
||||||
|
|
||||||
工作站通过 `workflow_sequence` 属性管理任务队列(JSON 字符串形式)。
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyWorkstation(WorkstationBase):
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self._workflow_sequence = []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workflow_sequence(self) -> str:
|
|
||||||
"""返回 JSON 字符串,ROS 自动发布"""
|
|
||||||
import json
|
|
||||||
return json.dumps(self._workflow_sequence)
|
|
||||||
|
|
||||||
async def append_to_workflow_sequence(self, workflow_name: str) -> Dict[str, Any]:
|
|
||||||
"""添加工作流到队列"""
|
|
||||||
self._workflow_sequence.append({
|
|
||||||
"name": workflow_name,
|
|
||||||
"status": "pending",
|
|
||||||
"created_at": time.time(),
|
|
||||||
})
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
async def clear_workflows(self) -> Dict[str, Any]:
|
|
||||||
"""清空工作流队列"""
|
|
||||||
self._workflow_sequence = []
|
|
||||||
return {"success": True}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 站间物料转移
|
|
||||||
|
|
||||||
工作站之间转移物料的模式。通过 ROS ActionClient 调用目标站的动作。
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def transfer_materials_to_another_station(
|
|
||||||
self,
|
|
||||||
target_device_id: str,
|
|
||||||
transfer_groups: list,
|
|
||||||
**kwargs,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""将物料转移到另一个工作站"""
|
|
||||||
target_node = self._children.get(target_device_id)
|
|
||||||
if not target_node:
|
|
||||||
# 通过 ROS 节点查找非子设备的目标站
|
|
||||||
pass
|
|
||||||
|
|
||||||
for group in transfer_groups:
|
|
||||||
resource = self.find_resource_by_name(group["resource_name"])
|
|
||||||
# 从本站 deck 移除
|
|
||||||
resource.unassign()
|
|
||||||
# 调用目标站的接收方法
|
|
||||||
# ...
|
|
||||||
|
|
||||||
return {"success": True, "transferred": len(transfer_groups)}
|
|
||||||
```
|
|
||||||
|
|
||||||
参考:`BioyondDispensingStation.transfer_materials_to_reaction_station`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. post_init 完整模式
|
|
||||||
|
|
||||||
`post_init` 是工作站初始化的关键阶段,此时 ROS 节点和子设备已就绪。
|
|
||||||
|
|
||||||
```python
|
|
||||||
def post_init(self, ros_node):
|
|
||||||
super().post_init(ros_node)
|
|
||||||
|
|
||||||
# 1. 初始化外部系统客户端(此时 config 已可用)
|
|
||||||
self.rpc_client = MySystemRPC(
|
|
||||||
host=self.config.get("api_host"),
|
|
||||||
api_key=self.config.get("api_key"),
|
|
||||||
)
|
|
||||||
self.hardware_interface = self.rpc_client
|
|
||||||
|
|
||||||
# 2. 启动连接监控
|
|
||||||
self.connection_monitor = ConnectionMonitor(self)
|
|
||||||
self.connection_monitor.start()
|
|
||||||
|
|
||||||
# 3. 启动 HTTP 回调服务
|
|
||||||
if hasattr(self, '_http_service_config'):
|
|
||||||
self.http_service = WorkstationHTTPService(
|
|
||||||
workstation_instance=self,
|
|
||||||
host=self._http_service_config["host"],
|
|
||||||
port=self._http_service_config["port"],
|
|
||||||
)
|
|
||||||
self.http_service.start()
|
|
||||||
|
|
||||||
# 4. 上传 deck 到云端
|
|
||||||
ROS2DeviceNode.run_async_func(
|
|
||||||
self._ros_node.update_resource, True,
|
|
||||||
**{"resources": [self.deck]}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 初始化资源同步器(可选)
|
|
||||||
self.resource_synchronizer = MyResourceSynchronizer(self, self.rpc_client)
|
|
||||||
```
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
---
|
|
||||||
name: create-device-skill
|
|
||||||
description: Create a skill for any Uni-Lab device by extracting action schemas from the device registry. Use when the user wants to create a new device skill, add device API documentation, or set up action schemas for a device.
|
|
||||||
---
|
|
||||||
|
|
||||||
# 创建设备 Skill 指南
|
|
||||||
|
|
||||||
本 meta-skill 教你如何为任意 Uni-Lab-OS 设备创建完整的 API 操作技能(参考 `unilab-device-api` 的成功案例)。
|
|
||||||
|
|
||||||
## 数据源
|
|
||||||
|
|
||||||
- **设备注册表**: `unilabos_data/req_device_registry_upload.json`
|
|
||||||
- **结构**: `{ "resources": [{ "id": "<device_id>", "class": { "module": "<python_module:ClassName>", "action_value_mappings": { ... } } }] }`
|
|
||||||
- **生成时机**: `unilab` 启动并完成注册表上传后自动生成
|
|
||||||
- **module 字段**: 格式 `unilabos.devices.xxx.yyy:ClassName`,可转为源码路径 `unilabos/devices/xxx/yyy.py`,阅读源码可了解参数含义和设备行为
|
|
||||||
|
|
||||||
## 创建流程
|
|
||||||
|
|
||||||
### Step 0 — 收集必备信息(缺一不可,否则询问后终止)
|
|
||||||
|
|
||||||
开始前**必须**确认以下 4 项信息全部就绪。如果用户未提供任何一项,**立即询问并终止当前流程**,等用户补齐后再继续。
|
|
||||||
|
|
||||||
向用户提问:「请提供你的 unilab 启动参数,我需要以下信息:」
|
|
||||||
|
|
||||||
#### 必备项 ①:ak / sk(认证凭据)
|
|
||||||
|
|
||||||
来源:启动命令的 `--ak` `--sk` 参数,或 config.py 中的 `ak = "..."` `sk = "..."`。
|
|
||||||
|
|
||||||
获取后立即生成 AUTH token:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/gen_auth.py <ak> <sk>
|
|
||||||
# 或从 config.py 提取
|
|
||||||
python ./scripts/gen_auth.py --config <config.py>
|
|
||||||
```
|
|
||||||
|
|
||||||
认证算法:`base64(ak:sk)` → `Authorization: Lab <token>`
|
|
||||||
|
|
||||||
#### 必备项 ②:--addr(目标环境)
|
|
||||||
|
|
||||||
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
|
|
||||||
|
|
||||||
| `--addr` 值 | BASE URL |
|
|
||||||
|-------------|----------|
|
|
||||||
| `test` | `https://uni-lab.test.bohrium.com` |
|
|
||||||
| `uat` | `https://uni-lab.uat.bohrium.com` |
|
|
||||||
| `local` | `http://127.0.0.1:48197` |
|
|
||||||
| 不传(默认) | `https://uni-lab.bohrium.com` |
|
|
||||||
| 其他自定义 URL | 直接使用该 URL |
|
|
||||||
|
|
||||||
#### 必备项 ③:req_device_registry_upload.json(设备注册表)
|
|
||||||
|
|
||||||
数据文件由 `unilab` 启动时自动生成,需要定位它:
|
|
||||||
|
|
||||||
**推断 working_dir**(即 `unilabos_data` 所在目录):
|
|
||||||
|
|
||||||
| 条件 | working_dir 取值 |
|
|
||||||
|------|------------------|
|
|
||||||
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
|
|
||||||
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
|
|
||||||
| 都没传 | `<当前工作目录>/unilabos_data/` |
|
|
||||||
|
|
||||||
**按优先级搜索文件**:
|
|
||||||
|
|
||||||
```
|
|
||||||
<推断的 working_dir>/unilabos_data/req_device_registry_upload.json
|
|
||||||
<推断的 working_dir>/req_device_registry_upload.json
|
|
||||||
<workspace 根目录>/unilabos_data/req_device_registry_upload.json
|
|
||||||
```
|
|
||||||
|
|
||||||
也可以直接 Glob 搜索:`**/req_device_registry_upload.json`
|
|
||||||
|
|
||||||
找到后**必须检查文件修改时间**并告知用户:「找到注册表文件 `<路径>`,生成于 `<时间>`。请确认这是最近一次启动生成的。」超过 1 天提醒用户是否需要重新启动 `unilab`。
|
|
||||||
|
|
||||||
**如果文件不存在** → 告知用户先运行 `unilab` 启动命令,等日志出现 `注册表响应数据已保存` 后再执行本流程。**终止。**
|
|
||||||
|
|
||||||
#### 必备项 ④:目标设备
|
|
||||||
|
|
||||||
用户需要明确要为哪个设备创建 skill。可以是设备名称(如「PRCXI 移液站」)或 device_id(如 `liquid_handler.prcxi`)。
|
|
||||||
|
|
||||||
如果用户不确定,运行提取脚本列出所有设备供选择:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/extract_device_actions.py --registry <找到的文件路径>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 完整示例
|
|
||||||
|
|
||||||
用户提供:
|
|
||||||
|
|
||||||
```
|
|
||||||
--ak a1fd9d4e-xxxx-xxxx-xxxx-d9a69c09f0fd
|
|
||||||
--sk 136ff5c6-xxxx-xxxx-xxxx-a03e301f827b
|
|
||||||
--addr test
|
|
||||||
--port 8003
|
|
||||||
--disable_browser
|
|
||||||
```
|
|
||||||
|
|
||||||
从中提取:
|
|
||||||
- ✅ ak/sk → 运行 `gen_auth.py` 得到 `AUTH="Authorization: Lab YTFmZDlk..."`
|
|
||||||
- ✅ addr=test → `BASE=https://uni-lab.test.bohrium.com`
|
|
||||||
- ✅ 搜索 `unilabos_data/req_device_registry_upload.json` → 找到并确认时间
|
|
||||||
- ✅ 用户指明目标设备 → 如 `liquid_handler.prcxi`
|
|
||||||
|
|
||||||
**四项全部就绪后才进入 Step 1。**
|
|
||||||
|
|
||||||
### Step 1 — 列出可用设备
|
|
||||||
|
|
||||||
运行提取脚本,列出所有设备及 action 数量和 Python 源码路径,让用户选择:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 自动搜索(默认在 unilabos_data/ 和当前目录查找)
|
|
||||||
python ./scripts/extract_device_actions.py
|
|
||||||
|
|
||||||
# 指定注册表文件路径
|
|
||||||
python ./scripts/extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本输出包含每个设备的 **Python 源码路径**(从 `class.module` 转换),可用于后续阅读源码理解参数含义。
|
|
||||||
|
|
||||||
### Step 2 — 提取 Action Schema
|
|
||||||
|
|
||||||
用户选择设备后,运行提取脚本:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./skills/<skill-name>/actions/
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
|
|
||||||
|
|
||||||
每个 action 生成一个 JSON 文件,包含:
|
|
||||||
- `type` — 作为 API 调用的 `action_type`
|
|
||||||
- `schema` — 完整 JSON Schema(含 `properties.goal.properties` 参数定义)
|
|
||||||
- `goal` — goal 字段映射(含占位符 `$placeholder`)
|
|
||||||
- `goal_default` — 默认值
|
|
||||||
|
|
||||||
### Step 3 — 写 action-index.md
|
|
||||||
|
|
||||||
按模板为每个 action 写条目:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
### `<action_name>`
|
|
||||||
|
|
||||||
<用途描述(一句话)>
|
|
||||||
|
|
||||||
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
|
|
||||||
- **核心参数**: `param1`, `param2`(从 schema.required 获取)
|
|
||||||
- **可选参数**: `param3`, `param4`
|
|
||||||
- **占位符字段**: `field`(需填入物料信息,值以 `$` 开头)
|
|
||||||
```
|
|
||||||
|
|
||||||
描述规则:
|
|
||||||
- 从 `schema.properties` 读参数列表(schema 已提升为 goal 内容)
|
|
||||||
- 从 `schema.required` 区分核心/可选参数
|
|
||||||
- 按功能分类(移液、枪头、外设等)
|
|
||||||
- 标注 `placeholder_keys` 中的字段类型:
|
|
||||||
- `unilabos_resources` → **ResourceSlot**,填入 `{id, name, uuid}`(id 是路径格式,从资源树取物料节点)
|
|
||||||
- `unilabos_devices` → **DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device)
|
|
||||||
- `unilabos_nodes` → **NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
|
|
||||||
- `unilabos_class` → **ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
|
|
||||||
- array 类型字段 → `[{id, name, uuid}, ...]`
|
|
||||||
- 特殊:`create_resource` 的 `res_id`(ResourceSlot)可填不存在的路径
|
|
||||||
|
|
||||||
### Step 4 — 写 SKILL.md
|
|
||||||
|
|
||||||
直接复用 `unilab-device-api` 的 API 模板(10 个 endpoint),修改:
|
|
||||||
- 设备名称
|
|
||||||
- Action 数量
|
|
||||||
- 目录列表
|
|
||||||
- Session state 中的 `device_name`
|
|
||||||
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key)
|
|
||||||
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
|
|
||||||
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot(物料/设备/节点/类名)
|
|
||||||
|
|
||||||
API 模板结构:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 设备信息
|
|
||||||
- device_id, Python 源码路径, 设备类名
|
|
||||||
|
|
||||||
## 前置条件(缺一不可)
|
|
||||||
- ak/sk → AUTH, --addr → BASE URL
|
|
||||||
|
|
||||||
## Session State
|
|
||||||
- lab_uuid(通过 API #1 自动匹配,不要问用户), device_name
|
|
||||||
|
|
||||||
## API Endpoints (10 个)
|
|
||||||
# 注意:
|
|
||||||
# - #1 获取 lab 列表 + 自动匹配 lab_uuid(遍历 is_admin 的 lab,
|
|
||||||
# 调用 /lab/info/{uuid} 比对 access_key == ak)
|
|
||||||
# - #2 创建工作流用 POST /lab/workflow
|
|
||||||
# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid}
|
|
||||||
|
|
||||||
## Placeholder Slot 填写规则
|
|
||||||
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
|
|
||||||
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
|
|
||||||
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
|
|
||||||
- unilabos_class → ClassSlot → "class_name" 字符串
|
|
||||||
- 特例:create_resource 的 res_id 允许填不存在的路径
|
|
||||||
- 列出本设备所有 Slot 字段、类型及含义
|
|
||||||
|
|
||||||
## 渐进加载策略
|
|
||||||
## 完整工作流 Checklist
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5 — 验证
|
|
||||||
|
|
||||||
检查文件完整性:
|
|
||||||
- [ ] `SKILL.md` 包含 10 个 API endpoint
|
|
||||||
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则(ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表
|
|
||||||
- [ ] `action-index.md` 列出所有 action 并有描述
|
|
||||||
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件
|
|
||||||
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
|
|
||||||
- [ ] 描述能让 agent 判断该用哪个 action
|
|
||||||
|
|
||||||
## Action JSON 文件结构
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "LiquidHandlerTransfer", // → API 的 action_type
|
|
||||||
"goal": { // goal 字段映射
|
|
||||||
"sources": "sources",
|
|
||||||
"targets": "targets",
|
|
||||||
"tip_racks": "tip_racks",
|
|
||||||
"asp_vols": "asp_vols"
|
|
||||||
},
|
|
||||||
"schema": { // ← 直接是 goal 的 schema(已提升)
|
|
||||||
"type": "object",
|
|
||||||
"properties": { // 参数定义(即请求中 goal 的字段)
|
|
||||||
"sources": { "type": "array", "items": { "type": "object" } },
|
|
||||||
"targets": { "type": "array", "items": { "type": "object" } },
|
|
||||||
"asp_vols": { "type": "array", "items": { "type": "number" } }
|
|
||||||
},
|
|
||||||
"required": [...],
|
|
||||||
"_unilabos_placeholder_info": { // ← Slot 类型标记
|
|
||||||
"sources": "unilabos_resources",
|
|
||||||
"targets": "unilabos_resources",
|
|
||||||
"tip_racks": "unilabos_resources"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"goal_default": { ... }, // 默认值
|
|
||||||
"placeholder_keys": { // ← 汇总所有 Slot 字段
|
|
||||||
"sources": "unilabos_resources", // ResourceSlot
|
|
||||||
"targets": "unilabos_resources",
|
|
||||||
"tip_racks": "unilabos_resources",
|
|
||||||
"target_device_id": "unilabos_devices" // DeviceSlot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意**:`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
|
|
||||||
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段。
|
|
||||||
|
|
||||||
## Placeholder Slot 类型体系
|
|
||||||
|
|
||||||
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式:
|
|
||||||
|
|
||||||
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|
|
||||||
|---------------|-----------|---------|---------|
|
|
||||||
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
|
|
||||||
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点(type=device),路径字符串 |
|
|
||||||
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
|
|
||||||
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
|
|
||||||
|
|
||||||
### ResourceSlot(`unilabos_resources`)
|
|
||||||
|
|
||||||
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- 单个(schema type=object):`{"id": "/path/name", "name": "name", "uuid": "xxx"}`
|
|
||||||
- 数组(schema type=array):`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
|
|
||||||
- `id` 本身是从 parent 计算的路径格式
|
|
||||||
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置)
|
|
||||||
|
|
||||||
> **特例**:`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。
|
|
||||||
|
|
||||||
### DeviceSlot(`unilabos_devices`)
|
|
||||||
|
|
||||||
填写**设备路径字符串**。从资源树中筛选 type=device 的节点,从 parent 计算路径:
|
|
||||||
|
|
||||||
```
|
|
||||||
"/host_node"
|
|
||||||
"/bioyond_cell/reaction_station"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 只填路径字符串,不需要 `{id, uuid}` 对象
|
|
||||||
- 根据 action 语义选择正确的设备(如 `target_device_id` = 目标设备)
|
|
||||||
|
|
||||||
### NodeSlot(`unilabos_nodes`)
|
|
||||||
|
|
||||||
范围 = 设备 + 物料。即资源树中**所有节点**都可以选,填写**路径字符串**:
|
|
||||||
|
|
||||||
```
|
|
||||||
"/PRCXI/PRCXI_Deck"
|
|
||||||
```
|
|
||||||
|
|
||||||
- 使用场景:当参数既可能指向物料也可能指向设备时(如 `PumpTransferProtocol` 的 `from_vessel`/`to_vessel`,`create_resource` 的 `parent`)
|
|
||||||
|
|
||||||
### ClassSlot(`unilabos_class`)
|
|
||||||
|
|
||||||
填写注册表中已上报的**资源类 name**。从本地 `req_resource_registry_upload.json` 中查找:
|
|
||||||
|
|
||||||
```
|
|
||||||
"container"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通过 API #10 获取资源树
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
|
|
||||||
```
|
|
||||||
|
|
||||||
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name`、`uuid`、`type`、`parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。
|
|
||||||
|
|
||||||
## 最终目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
./<skill-name>/
|
|
||||||
├── SKILL.md # API 端点 + 渐进加载指引
|
|
||||||
├── action-index.md # 动作索引:描述/用途/核心参数
|
|
||||||
└── actions/ # 每个 action 的完整 JSON Schema
|
|
||||||
├── action1.json
|
|
||||||
├── action2.json
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
从 req_device_registry_upload.json 中提取指定设备的 action schema。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
# 列出所有设备及 action 数量(自动搜索注册表文件)
|
|
||||||
python extract_device_actions.py
|
|
||||||
|
|
||||||
# 指定注册表文件路径
|
|
||||||
python extract_device_actions.py --registry <path/to/req_device_registry_upload.json>
|
|
||||||
|
|
||||||
# 提取指定设备的 action 到目录
|
|
||||||
python extract_device_actions.py <device_id> <output_dir>
|
|
||||||
python extract_device_actions.py --registry <path> <device_id> <output_dir>
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python extract_device_actions.py --registry unilabos_data/req_device_registry_upload.json
|
|
||||||
python extract_device_actions.py liquid_handler.prcxi .cursor/skills/unilab-device-api/actions/
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
REGISTRY_FILENAME = "req_device_registry_upload.json"
|
|
||||||
|
|
||||||
def find_registry(explicit_path=None):
|
|
||||||
"""
|
|
||||||
查找 req_device_registry_upload.json 文件。
|
|
||||||
|
|
||||||
搜索优先级:
|
|
||||||
1. 用户通过 --registry 显式指定的路径
|
|
||||||
2. <cwd>/unilabos_data/req_device_registry_upload.json
|
|
||||||
3. <cwd>/req_device_registry_upload.json
|
|
||||||
4. <script所在目录>/../../.. (workspace根) 下的 unilabos_data/
|
|
||||||
5. 向上逐级搜索父目录(最多 5 层)
|
|
||||||
"""
|
|
||||||
if explicit_path:
|
|
||||||
if os.path.isfile(explicit_path):
|
|
||||||
return explicit_path
|
|
||||||
if os.path.isdir(explicit_path):
|
|
||||||
fp = os.path.join(explicit_path, REGISTRY_FILENAME)
|
|
||||||
if os.path.isfile(fp):
|
|
||||||
return fp
|
|
||||||
print(f"警告: 指定的路径不存在: {explicit_path}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
candidates = [
|
|
||||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
|
||||||
REGISTRY_FILENAME,
|
|
||||||
]
|
|
||||||
|
|
||||||
for c in candidates:
|
|
||||||
if os.path.isfile(c):
|
|
||||||
return c
|
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
workspace_root = os.path.normpath(os.path.join(script_dir, "..", "..", ".."))
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(workspace_root, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
|
|
||||||
cwd = os.getcwd()
|
|
||||||
for _ in range(5):
|
|
||||||
parent = os.path.dirname(cwd)
|
|
||||||
if parent == cwd:
|
|
||||||
break
|
|
||||||
cwd = parent
|
|
||||||
for c in candidates:
|
|
||||||
path = os.path.join(cwd, c)
|
|
||||||
if os.path.isfile(path):
|
|
||||||
return path
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def load_registry(path):
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
def list_devices(data):
|
|
||||||
"""列出所有包含 action_value_mappings 的设备,同时返回 module 路径"""
|
|
||||||
resources = data.get('resources', [])
|
|
||||||
devices = []
|
|
||||||
for res in resources:
|
|
||||||
rid = res.get('id', '')
|
|
||||||
cls = res.get('class', {})
|
|
||||||
avm = cls.get('action_value_mappings', {})
|
|
||||||
module = cls.get('module', '')
|
|
||||||
if avm:
|
|
||||||
devices.append((rid, len(avm), module))
|
|
||||||
return devices
|
|
||||||
|
|
||||||
def flatten_schema_to_goal(action_data):
|
|
||||||
"""将 schema 中嵌套的 goal 内容提升为顶层 schema,去掉 feedback/result 包装"""
|
|
||||||
schema = action_data.get('schema', {})
|
|
||||||
goal_schema = schema.get('properties', {}).get('goal', {})
|
|
||||||
if goal_schema:
|
|
||||||
action_data = dict(action_data)
|
|
||||||
action_data['schema'] = goal_schema
|
|
||||||
return action_data
|
|
||||||
|
|
||||||
|
|
||||||
def extract_actions(data, device_id, output_dir):
|
|
||||||
"""提取指定设备的 action schema 到独立 JSON 文件"""
|
|
||||||
resources = data.get('resources', [])
|
|
||||||
for res in resources:
|
|
||||||
if res.get('id') == device_id:
|
|
||||||
cls = res.get('class', {})
|
|
||||||
module = cls.get('module', '')
|
|
||||||
avm = cls.get('action_value_mappings', {})
|
|
||||||
if not avm:
|
|
||||||
print(f"设备 {device_id} 没有 action_value_mappings")
|
|
||||||
return []
|
|
||||||
|
|
||||||
if module:
|
|
||||||
py_path = module.split(":")[0].replace(".", "/") + ".py"
|
|
||||||
class_name = module.split(":")[-1] if ":" in module else ""
|
|
||||||
print(f"Python 源码: {py_path}")
|
|
||||||
if class_name:
|
|
||||||
print(f"设备类: {class_name}")
|
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
written = []
|
|
||||||
for action_name in sorted(avm.keys()):
|
|
||||||
action_data = flatten_schema_to_goal(avm[action_name])
|
|
||||||
filename = action_name.replace('-', '_') + '.json'
|
|
||||||
filepath = os.path.join(output_dir, filename)
|
|
||||||
with open(filepath, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(action_data, f, indent=2, ensure_ascii=False)
|
|
||||||
written.append(filename)
|
|
||||||
print(f" {filepath}")
|
|
||||||
return written
|
|
||||||
|
|
||||||
print(f"设备 {device_id} 未找到")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = sys.argv[1:]
|
|
||||||
explicit_registry = None
|
|
||||||
|
|
||||||
if "--registry" in args:
|
|
||||||
idx = args.index("--registry")
|
|
||||||
if idx + 1 < len(args):
|
|
||||||
explicit_registry = args[idx + 1]
|
|
||||||
args = args[:idx] + args[idx + 2:]
|
|
||||||
else:
|
|
||||||
print("错误: --registry 需要指定路径")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
registry_path = find_registry(explicit_registry)
|
|
||||||
if not registry_path:
|
|
||||||
print(f"错误: 找不到 {REGISTRY_FILENAME}")
|
|
||||||
print()
|
|
||||||
print("解决方法:")
|
|
||||||
print(" 1. 先运行 unilab 启动命令,等待注册表生成")
|
|
||||||
print(" 2. 用 --registry 指定文件路径:")
|
|
||||||
print(f" python {sys.argv[0]} --registry <path/to/{REGISTRY_FILENAME}>")
|
|
||||||
print()
|
|
||||||
print("搜索过的路径:")
|
|
||||||
for p in [
|
|
||||||
os.path.join("unilabos_data", REGISTRY_FILENAME),
|
|
||||||
REGISTRY_FILENAME,
|
|
||||||
os.path.join("<workspace_root>", "unilabos_data", REGISTRY_FILENAME),
|
|
||||||
]:
|
|
||||||
print(f" - {p}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"注册表: {registry_path}")
|
|
||||||
mtime = os.path.getmtime(registry_path)
|
|
||||||
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
size_mb = os.path.getsize(registry_path) / (1024 * 1024)
|
|
||||||
print(f"生成时间: {gen_time} (文件大小: {size_mb:.1f} MB)")
|
|
||||||
data = load_registry(registry_path)
|
|
||||||
|
|
||||||
if len(args) == 0:
|
|
||||||
devices = list_devices(data)
|
|
||||||
print(f"\n找到 {len(devices)} 个设备:")
|
|
||||||
print(f"{'设备 ID':<50} {'Actions':>7} {'Python 模块'}")
|
|
||||||
print("-" * 120)
|
|
||||||
for did, count, module in sorted(devices, key=lambda x: x[0]):
|
|
||||||
py_path = module.split(":")[0].replace(".", "/") + ".py" if module else ""
|
|
||||||
print(f"{did:<50} {count:>7} {py_path}")
|
|
||||||
|
|
||||||
elif len(args) == 2:
|
|
||||||
device_id = args[0]
|
|
||||||
output_dir = args[1]
|
|
||||||
print(f"\n提取 {device_id} 的 actions 到 {output_dir}/")
|
|
||||||
written = extract_actions(data, device_id, output_dir)
|
|
||||||
if written:
|
|
||||||
print(f"\n共写入 {len(written)} 个 action 文件")
|
|
||||||
|
|
||||||
else:
|
|
||||||
print("用法:")
|
|
||||||
print(" python extract_device_actions.py [--registry <path>] # 列出设备")
|
|
||||||
print(" python extract_device_actions.py [--registry <path>] <device_id> <dir> # 提取 actions")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
从 ak/sk 生成 UniLab API Authorization header。
|
|
||||||
|
|
||||||
算法: base64(ak:sk) → "Authorization: Lab <token>"
|
|
||||||
|
|
||||||
用法:
|
|
||||||
python gen_auth.py <ak> <sk>
|
|
||||||
python gen_auth.py --config <config.py>
|
|
||||||
|
|
||||||
示例:
|
|
||||||
python gen_auth.py myak mysk
|
|
||||||
python gen_auth.py --config experiments/config.py
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def gen_auth(ak: str, sk: str) -> str:
|
|
||||||
token = base64.b64encode(f"{ak}:{sk}".encode("utf-8")).decode("utf-8")
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def extract_from_config(config_path: str) -> tuple:
|
|
||||||
"""从 config.py 中提取 ak 和 sk"""
|
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
ak_match = re.search(r'''ak\s*=\s*["']([^"']+)["']''', content)
|
|
||||||
sk_match = re.search(r'''sk\s*=\s*["']([^"']+)["']''', content)
|
|
||||||
if not ak_match or not sk_match:
|
|
||||||
return None, None
|
|
||||||
return ak_match.group(1), sk_match.group(1)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = sys.argv[1:]
|
|
||||||
|
|
||||||
if len(args) == 2 and args[0] == "--config":
|
|
||||||
ak, sk = extract_from_config(args[1])
|
|
||||||
if not ak or not sk:
|
|
||||||
print(f"错误: 在 {args[1]} 中未找到 ak/sk 配置")
|
|
||||||
print("期望格式: ak = \"xxx\" sk = \"xxx\"")
|
|
||||||
sys.exit(1)
|
|
||||||
print(f"配置文件: {args[1]}")
|
|
||||||
elif len(args) == 2:
|
|
||||||
ak, sk = args
|
|
||||||
else:
|
|
||||||
print("用法:")
|
|
||||||
print(" python gen_auth.py <ak> <sk>")
|
|
||||||
print(" python gen_auth.py --config <config.py>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
token = gen_auth(ak, sk)
|
|
||||||
print(f"ak: {ak}")
|
|
||||||
print(f"sk: {sk}")
|
|
||||||
print()
|
|
||||||
print(f"Authorization header:")
|
|
||||||
print(f" Authorization: Lab {token}")
|
|
||||||
print()
|
|
||||||
print(f"curl 用法:")
|
|
||||||
print(f' curl -H "Authorization: Lab {token}" ...')
|
|
||||||
print()
|
|
||||||
print(f"Shell 变量:")
|
|
||||||
print(f' AUTH="Authorization: Lab {token}"')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
19
.github/dependabot.yml
vendored
19
.github/dependabot.yml
vendored
@@ -1,19 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# GitHub Actions
|
|
||||||
- package-ecosystem: "github-actions"
|
|
||||||
directory: "/"
|
|
||||||
target-branch: "dev"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
day: "monday"
|
|
||||||
time: "06:00"
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
reviewers:
|
|
||||||
- "msgcenterpy-team"
|
|
||||||
labels:
|
|
||||||
- "dependencies"
|
|
||||||
- "github-actions"
|
|
||||||
commit-message:
|
|
||||||
prefix: "ci"
|
|
||||||
include: "scope"
|
|
||||||
67
.github/workflows/ci-check.yml
vendored
67
.github/workflows/ci-check.yml
vendored
@@ -1,67 +0,0 @@
|
|||||||
name: CI Check
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, dev]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, dev]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
registry-check:
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Fix Unicode encoding issue on Windows runner (cp1252 -> utf-8)
|
|
||||||
PYTHONIOENCODING: utf-8
|
|
||||||
PYTHONUTF8: 1
|
|
||||||
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: cmd
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Setup Miniforge
|
|
||||||
uses: conda-incubator/setup-miniconda@v3
|
|
||||||
with:
|
|
||||||
miniforge-version: latest
|
|
||||||
use-mamba: true
|
|
||||||
channels: robostack-staging,conda-forge,uni-lab
|
|
||||||
channel-priority: flexible
|
|
||||||
activate-environment: check-env
|
|
||||||
auto-update-conda: false
|
|
||||||
show-channel-urls: true
|
|
||||||
|
|
||||||
- name: Install ROS dependencies, uv and unilabos-msgs
|
|
||||||
run: |
|
|
||||||
echo Installing ROS dependencies...
|
|
||||||
mamba install -n check-env conda-forge::uv conda-forge::opencv robostack-staging::ros-humble-ros-core robostack-staging::ros-humble-action-msgs robostack-staging::ros-humble-std-msgs robostack-staging::ros-humble-geometry-msgs robostack-staging::ros-humble-control-msgs robostack-staging::ros-humble-nav2-msgs uni-lab::ros-humble-unilabos-msgs robostack-staging::ros-humble-cv-bridge robostack-staging::ros-humble-vision-opencv robostack-staging::ros-humble-tf-transformations robostack-staging::ros-humble-moveit-msgs robostack-staging::ros-humble-tf2-ros robostack-staging::ros-humble-tf2-ros-py conda-forge::transforms3d -c robostack-staging -c conda-forge -c uni-lab -y
|
|
||||||
|
|
||||||
- name: Install pip dependencies and unilabos
|
|
||||||
run: |
|
|
||||||
call conda activate check-env
|
|
||||||
echo Installing pip dependencies...
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
uv pip install pywinauto git+https://github.com/Xuwznln/pylabrobot.git
|
|
||||||
uv pip uninstall enum34 || echo enum34 not installed, skipping
|
|
||||||
uv pip install .
|
|
||||||
|
|
||||||
- name: Run check mode (AST registry validation)
|
|
||||||
run: |
|
|
||||||
call conda activate check-env
|
|
||||||
echo Running check mode...
|
|
||||||
python -m unilabos --check_mode --skip_env_check
|
|
||||||
|
|
||||||
- name: Check for uncommitted changes
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if ! git diff --exit-code; then
|
|
||||||
echo "::error::检测到文件变化!请先在本地运行 'python -m unilabos --complete_registry' 并提交变更"
|
|
||||||
echo "变化的文件:"
|
|
||||||
git diff --name-only
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "检查通过:无文件变化"
|
|
||||||
41
.github/workflows/conda-pack-build.yml
vendored
41
.github/workflows/conda-pack-build.yml
vendored
@@ -13,11 +13,6 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: 'win-64'
|
default: 'win-64'
|
||||||
type: string
|
type: string
|
||||||
build_full:
|
|
||||||
description: '是否构建完整版 unilabos-full (默认构建轻量版 unilabos)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-conda-pack:
|
build-conda-pack:
|
||||||
@@ -29,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-15 # Intel (via Rosetta)
|
- os: macos-13 # Intel
|
||||||
platform: osx-64
|
platform: osx-64
|
||||||
env_file: unilabos-osx-64.yaml
|
env_file: unilabos-osx-64.yaml
|
||||||
script_ext: sh
|
script_ext: sh
|
||||||
@@ -62,7 +57,7 @@ jobs:
|
|||||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.branch }}
|
ref: ${{ github.event.inputs.branch }}
|
||||||
@@ -74,7 +69,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
miniforge-version: latest
|
miniforge-version: latest
|
||||||
use-mamba: true
|
use-mamba: true
|
||||||
python-version: '3.11.14'
|
python-version: '3.11.11'
|
||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
channel-priority: flexible
|
channel-priority: flexible
|
||||||
activate-environment: unilab
|
activate-environment: unilab
|
||||||
@@ -86,14 +81,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo Installing unilabos and dependencies to unilab environment...
|
echo Installing unilabos and dependencies to unilab environment...
|
||||||
echo Using mamba for faster and more reliable dependency resolution...
|
echo Using mamba for faster and more reliable dependency resolution...
|
||||||
echo Build full: ${{ github.event.inputs.build_full }}
|
|
||||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
|
||||||
echo Installing unilabos-full ^(complete package^)...
|
|
||||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
|
||||||
) else (
|
|
||||||
echo Installing unilabos ^(minimal package^)...
|
|
||||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
)
|
|
||||||
|
|
||||||
- name: Install conda-pack, unilabos and dependencies (Unix)
|
- name: Install conda-pack, unilabos and dependencies (Unix)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
if: steps.should_build.outputs.should_build == 'true' && matrix.platform != 'win-64'
|
||||||
@@ -101,14 +89,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "Installing unilabos and dependencies to unilab environment..."
|
echo "Installing unilabos and dependencies to unilab environment..."
|
||||||
echo "Using mamba for faster and more reliable dependency resolution..."
|
echo "Using mamba for faster and more reliable dependency resolution..."
|
||||||
echo "Build full: ${{ github.event.inputs.build_full }}"
|
|
||||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
|
||||||
echo "Installing unilabos-full (complete package)..."
|
|
||||||
mamba install -n unilab uni-lab::unilabos-full conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
|
||||||
else
|
|
||||||
echo "Installing unilabos (minimal package)..."
|
|
||||||
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
mamba install -n unilab uni-lab::unilabos conda-pack -c uni-lab -c robostack-staging -c conda-forge -y
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
- name: Get latest ros-humble-unilabos-msgs version (Windows)
|
||||||
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
if: steps.should_build.outputs.should_build == 'true' && matrix.platform == 'win-64'
|
||||||
@@ -312,7 +293,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload distribution package
|
- name: Upload distribution package
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
name: unilab-pack-${{ matrix.platform }}-${{ github.event.inputs.branch }}
|
||||||
path: dist-package/
|
path: dist-package/
|
||||||
@@ -327,12 +308,7 @@ jobs:
|
|||||||
echo ==========================================
|
echo ==========================================
|
||||||
echo Platform: ${{ matrix.platform }}
|
echo Platform: ${{ matrix.platform }}
|
||||||
echo Branch: ${{ github.event.inputs.branch }}
|
echo Branch: ${{ github.event.inputs.branch }}
|
||||||
echo Python version: 3.11.14
|
echo Python version: 3.11.11
|
||||||
if "${{ github.event.inputs.build_full }}"=="true" (
|
|
||||||
echo Package: unilabos-full ^(complete^)
|
|
||||||
) else (
|
|
||||||
echo Package: unilabos ^(minimal^)
|
|
||||||
)
|
|
||||||
echo.
|
echo.
|
||||||
echo Distribution package contents:
|
echo Distribution package contents:
|
||||||
dir dist-package
|
dir dist-package
|
||||||
@@ -352,12 +328,7 @@ jobs:
|
|||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "Platform: ${{ matrix.platform }}"
|
echo "Platform: ${{ matrix.platform }}"
|
||||||
echo "Branch: ${{ github.event.inputs.branch }}"
|
echo "Branch: ${{ github.event.inputs.branch }}"
|
||||||
echo "Python version: 3.11.14"
|
echo "Python version: 3.11.11"
|
||||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
|
||||||
echo "Package: unilabos-full (complete)"
|
|
||||||
else
|
|
||||||
echo "Package: unilabos (minimal)"
|
|
||||||
fi
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Distribution package contents:"
|
echo "Distribution package contents:"
|
||||||
ls -lh dist-package/
|
ls -lh dist-package/
|
||||||
|
|||||||
37
.github/workflows/deploy-docs.yml
vendored
37
.github/workflows/deploy-docs.yml
vendored
@@ -1,12 +1,10 @@
|
|||||||
name: Deploy Docs
|
name: Deploy Docs
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# 在 CI Check 成功后自动触发(仅 main 分支)
|
push:
|
||||||
workflow_run:
|
branches: [main]
|
||||||
workflows: ["CI Check"]
|
pull_request:
|
||||||
types: [completed]
|
|
||||||
branches: [main]
|
branches: [main]
|
||||||
# 手动触发
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
branch:
|
branch:
|
||||||
@@ -35,19 +33,12 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
# Build documentation
|
# Build documentation
|
||||||
build:
|
build:
|
||||||
# 只在以下情况运行:
|
|
||||||
# 1. workflow_run 触发且 CI Check 成功
|
|
||||||
# 2. 手动触发
|
|
||||||
if: |
|
|
||||||
github.event_name == 'workflow_dispatch' ||
|
|
||||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# workflow_run 时使用触发工作流的分支,手动触发时使用输入的分支
|
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||||
ref: ${{ github.event.workflow_run.head_branch || github.event.inputs.branch || github.ref }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Miniforge (with mamba)
|
- name: Setup Miniforge (with mamba)
|
||||||
@@ -55,7 +46,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
miniforge-version: latest
|
miniforge-version: latest
|
||||||
use-mamba: true
|
use-mamba: true
|
||||||
python-version: '3.11.14'
|
python-version: '3.11.11'
|
||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
channel-priority: flexible
|
channel-priority: flexible
|
||||||
activate-environment: unilab
|
activate-environment: unilab
|
||||||
@@ -84,10 +75,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
id: pages
|
id: pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v4
|
||||||
if: |
|
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||||
github.event.workflow_run.head_branch == 'main' ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
|
||||||
|
|
||||||
- name: Build Sphinx documentation
|
- name: Build Sphinx documentation
|
||||||
run: |
|
run: |
|
||||||
@@ -105,18 +94,14 @@ jobs:
|
|||||||
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
|
test -f docs/_build/html/index.html && echo "✓ index.html exists" || echo "✗ index.html missing"
|
||||||
|
|
||||||
- name: Upload build artifacts
|
- name: Upload build artifacts
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-pages-artifact@v3
|
||||||
if: |
|
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||||
github.event.workflow_run.head_branch == 'main' ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
|
||||||
with:
|
with:
|
||||||
path: docs/_build/html
|
path: docs/_build/html
|
||||||
|
|
||||||
# Deploy to GitHub Pages
|
# Deploy to GitHub Pages
|
||||||
deploy:
|
deploy:
|
||||||
if: |
|
if: github.ref == 'refs/heads/main' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
||||||
github.event.workflow_run.head_branch == 'main' ||
|
|
||||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_to_pages == 'true')
|
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|||||||
48
.github/workflows/multi-platform-build.yml
vendored
48
.github/workflows/multi-platform-build.yml
vendored
@@ -1,16 +1,11 @@
|
|||||||
name: Multi-Platform Conda Build
|
name: Multi-Platform Conda Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# 在 CI Check 工作流完成后触发(仅限 main/dev 分支)
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["CI Check"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches: [main, dev]
|
|
||||||
# 支持 tag 推送(不依赖 CI Check)
|
|
||||||
push:
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
# 手动触发
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
platforms:
|
platforms:
|
||||||
@@ -22,37 +17,9 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
skip_ci_check:
|
|
||||||
description: '跳过等待 CI Check (手动触发时可选)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
|
||||||
wait-for-ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'workflow_run'
|
|
||||||
outputs:
|
|
||||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
|
||||||
steps:
|
|
||||||
- name: Check CI status
|
|
||||||
id: check
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
|
||||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "CI Check passed, proceeding with build"
|
|
||||||
else
|
|
||||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: [wait-for-ci]
|
|
||||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
|
||||||
if: |
|
|
||||||
always() &&
|
|
||||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -60,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-15 # Intel (via Rosetta)
|
- os: macos-13 # Intel
|
||||||
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
|
||||||
@@ -77,10 +44,8 @@ jobs:
|
|||||||
shell: bash -l {0}
|
shell: bash -l {0}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check if platform should be built
|
- name: Check if platform should be built
|
||||||
@@ -104,6 +69,7 @@ jobs:
|
|||||||
channels: conda-forge,robostack-staging,defaults
|
channels: conda-forge,robostack-staging,defaults
|
||||||
channel-priority: strict
|
channel-priority: strict
|
||||||
activate-environment: build-env
|
activate-environment: build-env
|
||||||
|
auto-activate-base: false
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
show-channel-urls: true
|
show-channel-urls: true
|
||||||
|
|
||||||
@@ -149,7 +115,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload conda package artifacts
|
- name: Upload conda package artifacts
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: conda-package-${{ matrix.platform }}
|
name: conda-package-${{ matrix.platform }}
|
||||||
path: conda-packages-temp
|
path: conda-packages-temp
|
||||||
|
|||||||
115
.github/workflows/unilabos-conda-build.yml
vendored
115
.github/workflows/unilabos-conda-build.yml
vendored
@@ -1,69 +1,32 @@
|
|||||||
name: UniLabOS Conda Build
|
name: UniLabOS Conda Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# 在 CI Check 成功后自动触发
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["CI Check"]
|
|
||||||
types: [completed]
|
|
||||||
branches: [main, dev]
|
|
||||||
# 标签推送时直接触发(发布版本)
|
|
||||||
push:
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
tags: ['v*']
|
tags: ['v*']
|
||||||
# 手动触发
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
platforms:
|
platforms:
|
||||||
description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64'
|
description: '选择构建平台 (逗号分隔): linux-64, osx-64, osx-arm64, win-64'
|
||||||
required: false
|
required: false
|
||||||
default: 'linux-64'
|
default: 'linux-64'
|
||||||
build_full:
|
|
||||||
description: '是否构建 unilabos-full 完整包 (默认只构建 unilabos 基础包)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
upload_to_anaconda:
|
upload_to_anaconda:
|
||||||
description: '是否上传到Anaconda.org'
|
description: '是否上传到Anaconda.org'
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
skip_ci_check:
|
|
||||||
description: '跳过等待 CI Check (手动触发时可选)'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# 等待 CI Check 完成的 job (仅用于 workflow_run 触发)
|
|
||||||
wait-for-ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name == 'workflow_run'
|
|
||||||
outputs:
|
|
||||||
should_continue: ${{ steps.check.outputs.should_continue }}
|
|
||||||
steps:
|
|
||||||
- name: Check CI status
|
|
||||||
id: check
|
|
||||||
run: |
|
|
||||||
if [[ "${{ github.event.workflow_run.conclusion }}" == "success" ]]; then
|
|
||||||
echo "should_continue=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "CI Check passed, proceeding with build"
|
|
||||||
else
|
|
||||||
echo "should_continue=false" >> $GITHUB_OUTPUT
|
|
||||||
echo "CI Check did not succeed (status: ${{ github.event.workflow_run.conclusion }}), skipping build"
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: [wait-for-ci]
|
|
||||||
# 运行条件:workflow_run 触发且 CI 成功,或者其他触发方式
|
|
||||||
if: |
|
|
||||||
always() &&
|
|
||||||
(needs.wait-for-ci.result == 'skipped' || needs.wait-for-ci.outputs.should_continue == 'true')
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
platform: linux-64
|
platform: linux-64
|
||||||
- os: macos-15 # Intel (via Rosetta)
|
- os: macos-13 # Intel
|
||||||
platform: osx-64
|
platform: osx-64
|
||||||
- os: macos-latest # ARM64
|
- os: macos-latest # ARM64
|
||||||
platform: osx-arm64
|
platform: osx-arm64
|
||||||
@@ -77,10 +40,8 @@ jobs:
|
|||||||
shell: bash -l {0}
|
shell: bash -l {0}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
# 如果是 workflow_run 触发,使用触发 CI Check 的 commit
|
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check if platform should be built
|
- name: Check if platform should be built
|
||||||
@@ -104,6 +65,7 @@ jobs:
|
|||||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||||
channel-priority: strict
|
channel-priority: strict
|
||||||
activate-environment: build-env
|
activate-environment: build-env
|
||||||
|
auto-activate-base: false
|
||||||
auto-update-conda: false
|
auto-update-conda: false
|
||||||
show-channel-urls: true
|
show-channel-urls: true
|
||||||
|
|
||||||
@@ -119,61 +81,12 @@ jobs:
|
|||||||
conda list | grep -E "(rattler-build|anaconda-client)"
|
conda list | grep -E "(rattler-build|anaconda-client)"
|
||||||
echo "Platform: ${{ matrix.platform }}"
|
echo "Platform: ${{ matrix.platform }}"
|
||||||
echo "OS: ${{ matrix.os }}"
|
echo "OS: ${{ matrix.os }}"
|
||||||
echo "Build full package: ${{ github.event.inputs.build_full || 'false' }}"
|
echo "Building UniLabOS package"
|
||||||
echo "Building packages:"
|
|
||||||
echo " - unilabos-env (environment dependencies)"
|
|
||||||
echo " - unilabos (with pip package)"
|
|
||||||
if [[ "${{ github.event.inputs.build_full }}" == "true" ]]; then
|
|
||||||
echo " - unilabos-full (complete package)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build unilabos-env (conda environment only, noarch)
|
- name: Build conda package
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "Building unilabos-env (conda environment dependencies)..."
|
rattler-build build -r .conda/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
||||||
rattler-build build -r .conda/environment/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge
|
|
||||||
|
|
||||||
- name: Upload unilabos-env to Anaconda.org (if enabled)
|
|
||||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
|
||||||
run: |
|
|
||||||
echo "Uploading unilabos-env to uni-lab organization..."
|
|
||||||
for package in $(find ./output -name "unilabos-env*.conda"); do
|
|
||||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Build unilabos (with pip package)
|
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
|
||||||
run: |
|
|
||||||
echo "Building unilabos package..."
|
|
||||||
# 如果已上传到 Anaconda,从 uni-lab channel 获取 unilabos-env;否则从本地 output 获取
|
|
||||||
rattler-build build -r .conda/base/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
|
||||||
|
|
||||||
- name: Upload unilabos to Anaconda.org (if enabled)
|
|
||||||
if: steps.should_build.outputs.should_build == 'true' && github.event.inputs.upload_to_anaconda == 'true'
|
|
||||||
run: |
|
|
||||||
echo "Uploading unilabos to uni-lab organization..."
|
|
||||||
for package in $(find ./output -name "unilabos-0*.conda" -o -name "unilabos-[0-9]*.conda"); do
|
|
||||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Build unilabos-full - Only when explicitly requested
|
|
||||||
if: |
|
|
||||||
steps.should_build.outputs.should_build == 'true' &&
|
|
||||||
github.event.inputs.build_full == 'true'
|
|
||||||
run: |
|
|
||||||
echo "Building unilabos-full package on ${{ matrix.platform }}..."
|
|
||||||
rattler-build build -r .conda/full/recipe.yaml -c uni-lab -c robostack-staging -c conda-forge --channel ./output
|
|
||||||
|
|
||||||
- name: Upload unilabos-full to Anaconda.org (if enabled)
|
|
||||||
if: |
|
|
||||||
steps.should_build.outputs.should_build == 'true' &&
|
|
||||||
github.event.inputs.build_full == 'true' &&
|
|
||||||
github.event.inputs.upload_to_anaconda == 'true'
|
|
||||||
run: |
|
|
||||||
echo "Uploading unilabos-full to uni-lab organization..."
|
|
||||||
for package in $(find ./output -name "unilabos-full*.conda"); do
|
|
||||||
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: List built packages
|
- name: List built packages
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
@@ -195,9 +108,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload conda package artifacts
|
- name: Upload conda package artifacts
|
||||||
if: steps.should_build.outputs.should_build == 'true'
|
if: steps.should_build.outputs.should_build == 'true'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: conda-package-unilabos-${{ matrix.platform }}
|
name: conda-package-unilabos-${{ matrix.platform }}
|
||||||
path: conda-packages-temp
|
path: conda-packages-temp
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Upload to Anaconda.org (uni-lab organization)
|
||||||
|
if: github.event.inputs.upload_to_anaconda == 'true'
|
||||||
|
run: |
|
||||||
|
for package in $(find ./output -name "*.conda"); do
|
||||||
|
echo "Uploading $package to uni-lab organization..."
|
||||||
|
anaconda -t ${{ secrets.ANACONDA_API_TOKEN }} upload --user uni-lab --force "$package"
|
||||||
|
done
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,11 +1,8 @@
|
|||||||
cursor_docs/
|
|
||||||
configs/
|
configs/
|
||||||
temp/
|
temp/
|
||||||
output/
|
output/
|
||||||
unilabos_data/
|
unilabos_data/
|
||||||
pyrightconfig.json
|
pyrightconfig.json
|
||||||
.cursorignore
|
|
||||||
device_package*/
|
|
||||||
## Python
|
## Python
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
|
|||||||
87
AGENTS.md
87
AGENTS.md
@@ -1,87 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
Also follow the monorepo-level rules in `../AGENTS.md`.
|
|
||||||
|
|
||||||
## Build & Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install in editable mode (requires mamba env with python 3.11)
|
|
||||||
pip install -e .
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
|
|
||||||
# Run with a device graph
|
|
||||||
unilab --graph <graph.json> --config <config.py> --backend ros
|
|
||||||
unilab --graph <graph.json> --config <config.py> --backend simple # no ROS2 needed
|
|
||||||
|
|
||||||
# Common CLI flags
|
|
||||||
unilab --app_bridges websocket fastapi # communication bridges
|
|
||||||
unilab --test_mode # simulate hardware, no real execution
|
|
||||||
unilab --check_mode # CI validation of registry imports
|
|
||||||
unilab --skip_env_check # skip auto-install of dependencies
|
|
||||||
unilab --visual rviz|web|disable # visualization mode
|
|
||||||
unilab --is_slave # run as slave node
|
|
||||||
|
|
||||||
# Workflow upload subcommand
|
|
||||||
unilab workflow_upload -f <workflow.json> -n <name> --tags tag1 tag2
|
|
||||||
|
|
||||||
# Tests
|
|
||||||
pytest tests/ # all tests
|
|
||||||
pytest tests/resources/test_resourcetreeset.py # single test file
|
|
||||||
pytest tests/resources/test_resourcetreeset.py::TestClassName::test_method # single test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Startup Flow
|
|
||||||
|
|
||||||
`unilab` CLI → `unilabos/app/main.py:main()` → loads config → builds registry → reads device graph (JSON/GraphML) → starts backend thread (ROS2/simple) → starts FastAPI web server + WebSocket client.
|
|
||||||
|
|
||||||
### Core Layers
|
|
||||||
|
|
||||||
**Registry** (`unilabos/registry/`): Singleton `Registry` class discovers and catalogs all device types, resource types, and communication devices from YAML definitions. Device types live in `registry/devices/*.yaml`, resources in `registry/resources/`, comms in `registry/device_comms/`. The registry resolves class paths to actual Python classes via `utils/import_manager.py`.
|
|
||||||
|
|
||||||
**Resource Tracking** (`unilabos/resources/resource_tracker.py`): Pydantic-based `ResourceDict` → `ResourceDictInstance` → `ResourceTreeSet` hierarchy. `ResourceTreeSet` is the canonical in-memory representation of all devices and resources, used throughout the system. Graph I/O is in `resources/graphio.py` (reads JSON/GraphML device topology files into `nx.Graph` + `ResourceTreeSet`).
|
|
||||||
|
|
||||||
**Device Drivers** (`unilabos/devices/`): 30+ hardware drivers organized by device type (liquid_handling, hplc, balance, arm, etc.). Each driver is a Python class that gets wrapped by `ros/device_node_wrapper.py:ros2_device_node()` to become a ROS2 node with publishers, subscribers, and action servers.
|
|
||||||
|
|
||||||
**ROS2 Layer** (`unilabos/ros/`): `device_node_wrapper.py` dynamically wraps any device class into `ROS2DeviceNode` (defined in `ros/nodes/base_device_node.py`). Preset node types in `ros/nodes/presets/` include `host_node`, `controller_node`, `workstation`, `serial_node`, `camera`. Messages use custom `unilabos_msgs` (pre-built, distributed via releases).
|
|
||||||
|
|
||||||
**Protocol Compilation** (`unilabos/compile/`): 20+ protocol compilers (add, centrifuge, dissolve, filter, heatchill, stir, pump, etc.) that transform YAML protocol definitions into executable sequences.
|
|
||||||
|
|
||||||
**Communication** (`unilabos/device_comms/`): Hardware communication adapters — OPC-UA client, Modbus PLC, RPC, and a universal driver. `app/communication.py` provides a factory pattern for WebSocket client connections to the cloud.
|
|
||||||
|
|
||||||
**Web/API** (`unilabos/app/web/`): FastAPI server with REST API (`api.py`), Jinja2 template pages (`pages.py`), and HTTP client for cloud communication (`client.py`). Runs on port 8002 by default.
|
|
||||||
|
|
||||||
### Configuration System
|
|
||||||
|
|
||||||
- **Config classes** in `unilabos/config/config.py`: `BasicConfig`, `WSConfig`, `HTTPConfig`, `ROSConfig` — all class-level attributes, loaded from Python config files
|
|
||||||
- Config files are `.py` files with matching class names (see `config/example_config.py`)
|
|
||||||
- Environment variables override with prefix `UNILABOS_` (e.g., `UNILABOS_BASICCONFIG_PORT=9000`)
|
|
||||||
- Device topology defined in graph files (JSON with node-link format, or GraphML)
|
|
||||||
|
|
||||||
### Key Data Flow
|
|
||||||
|
|
||||||
1. Graph file → `graphio.read_node_link_json()` → `(nx.Graph, ResourceTreeSet, resource_links)`
|
|
||||||
2. `ResourceTreeSet` + `Registry` → `initialize_device.initialize_device_from_dict()` → `ROS2DeviceNode` instances
|
|
||||||
3. Device nodes communicate via ROS2 topics/actions or direct Python calls (simple backend)
|
|
||||||
4. Cloud sync via WebSocket (`app/ws_client.py`) and HTTP (`app/web/client.py`)
|
|
||||||
|
|
||||||
### Test Data
|
|
||||||
|
|
||||||
Example device graphs and experiment configs are in `unilabos/test/experiments/` (not `tests/`). Registry test fixtures in `unilabos/test/registry/`.
|
|
||||||
|
|
||||||
## Code Conventions
|
|
||||||
|
|
||||||
- Code comments and log messages in simplified Chinese
|
|
||||||
- Python 3.11+, type hints expected
|
|
||||||
- Pydantic models for data validation (`resource_tracker.py`)
|
|
||||||
- Singleton pattern via `@singleton` decorator (`utils/decorator.py`)
|
|
||||||
- Dynamic class loading via `utils/import_manager.py` — device classes resolved at runtime from registry YAML paths
|
|
||||||
- CLI argument dashes auto-converted to underscores for consistency
|
|
||||||
|
|
||||||
## Licensing
|
|
||||||
|
|
||||||
- Framework code: GPL-3.0
|
|
||||||
- Device drivers (`unilabos/devices/`): DP Technology Proprietary License — do not redistribute
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
recursive-include unilabos/test *
|
recursive-include unilabos/test *
|
||||||
recursive-include unilabos/utils *
|
|
||||||
recursive-include unilabos/registry *.yaml
|
recursive-include unilabos/registry *.yaml
|
||||||
recursive-include unilabos/app/web/static *
|
recursive-include unilabos/app/web/static *
|
||||||
recursive-include unilabos/app/web/templates *
|
recursive-include unilabos/app/web/templates *
|
||||||
|
|||||||
17
NOTICE
17
NOTICE
@@ -1,17 +0,0 @@
|
|||||||
# 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.
|
|
||||||
90
README.md
90
README.md
@@ -8,13 +8,17 @@
|
|||||||
|
|
||||||
**English** | [中文](README_zh.md)
|
**English** | [中文](README_zh.md)
|
||||||
|
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
[](https://github.com/dptech-corp/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
|
||||||
@@ -27,89 +31,39 @@ Uni-Lab-OS is a platform for laboratory automation, designed to connect and cont
|
|||||||
|
|
||||||
Detailed documentation can be found at:
|
Detailed documentation can be found at:
|
||||||
|
|
||||||
- [Online Documentation](https://deepmodeling.github.io/Uni-Lab-OS/)
|
- [Online Documentation](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Setup Conda Environment
|
Uni-Lab-OS recommends using `mamba` for environment management. Choose the appropriate environment file for your operating system:
|
||||||
|
|
||||||
Uni-Lab-OS recommends using `mamba` for environment management. Choose the package that fits your needs:
|
|
||||||
|
|
||||||
| Package | Use Case | Contents |
|
|
||||||
|---------|----------|----------|
|
|
||||||
| `unilabos` | **Recommended for most users** | Complete package, ready to use |
|
|
||||||
| `unilabos-env` | Developers (editable install) | Environment only, install unilabos via pip |
|
|
||||||
| `unilabos-full` | Simulation/Visualization | unilabos + ROS2 Desktop + Gazebo + MoveIt |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create new environment
|
# Create new environment
|
||||||
mamba create -n unilab python=3.11.14
|
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||||
mamba activate unilab
|
|
||||||
|
|
||||||
# Option A: Standard installation (recommended for most users)
|
|
||||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
|
||||||
|
|
||||||
# Option B: For developers (editable mode development)
|
|
||||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
|
||||||
# Then install unilabos and dependencies:
|
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
|
||||||
pip install -e .
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
|
|
||||||
# Option C: Full installation (simulation/visualization)
|
|
||||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**When to use which?**
|
## Install Dev Uni-Lab-OS
|
||||||
- **unilabos**: Standard installation for production deployment and general usage (recommended)
|
|
||||||
- **unilabos-env**: For developers who need `pip install -e .` editable mode, modify source code
|
|
||||||
- **unilabos-full**: For simulation (Gazebo), visualization (rviz2), and Jupyter notebooks
|
|
||||||
|
|
||||||
### 2. Clone Repository (Optional, for developers)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository (only needed for development or examples)
|
# Clone the repository
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||||
cd Uni-Lab-OS
|
cd Uni-Lab-OS
|
||||||
|
|
||||||
|
# Install Uni-Lab-OS
|
||||||
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start Uni-Lab System
|
3. Start Uni-Lab System:
|
||||||
|
|
||||||
Please refer to [Documentation - Boot Examples](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
|
Please refer to [Documentation - Boot Examples](https://xuwznln.github.io/Uni-Lab-OS-Doc/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/deepmodeling/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/dptech-corp/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 uses a dual licensing structure:
|
This project is licensed under GPL-3.0 - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
- **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
|
||||||
|
|
||||||
@@ -121,4 +75,4 @@ See [NOTICE](NOTICE) for complete licensing details.
|
|||||||
|
|
||||||
## Contact Us
|
## Contact Us
|
||||||
|
|
||||||
- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
|
|||||||
90
README_zh.md
90
README_zh.md
@@ -8,13 +8,17 @@
|
|||||||
|
|
||||||
[English](README.md) | **中文**
|
[English](README.md) | **中文**
|
||||||
|
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/stargazers)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/stargazers)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/network/members)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/network/members)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
[](https://github.com/deepmodeling/Uni-Lab-OS/blob/main/LICENSE)
|
[](https://github.com/dptech-corp/Uni-Lab-OS/blob/main/LICENSE)
|
||||||
|
|
||||||
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控制各种实验设备,实现实验流程的自动化和标准化。
|
||||||
|
|
||||||
|
## 🏆 比赛
|
||||||
|
|
||||||
|
欢迎参加[有机化学合成智能实验大赛](https://bohrium.dp.tech/competitions/1451645258),使用 Uni-Lab-OS 探索自动化合成!
|
||||||
|
|
||||||
## 核心特点
|
## 核心特点
|
||||||
|
|
||||||
- 多设备集成管理
|
- 多设备集成管理
|
||||||
@@ -27,89 +31,41 @@ Uni-Lab-OS 是一个用于实验室自动化的综合平台,旨在连接和控
|
|||||||
|
|
||||||
详细文档可在以下位置找到:
|
详细文档可在以下位置找到:
|
||||||
|
|
||||||
- [在线文档](https://deepmodeling.github.io/Uni-Lab-OS/)
|
- [在线文档](https://xuwznln.github.io/Uni-Lab-OS-Doc/)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 配置 Conda 环境
|
1. 配置 Conda 环境
|
||||||
|
|
||||||
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的需求选择合适的安装包:
|
Uni-Lab-OS 建议使用 `mamba` 管理环境。根据您的操作系统选择适当的环境文件:
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含内容 |
|
|
||||||
|--------|----------|----------|
|
|
||||||
| `unilabos` | **推荐大多数用户** | 完整安装包,开箱即用 |
|
|
||||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
|
||||||
| `unilabos-full` | 仿真/可视化 | unilabos + ROS2 桌面版 + Gazebo + MoveIt |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建新环境
|
# 创建新环境
|
||||||
mamba create -n unilab python=3.11.14
|
mamba create -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||||
mamba activate unilab
|
|
||||||
|
|
||||||
# 方案 A:标准安装(推荐大多数用户)
|
|
||||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
|
||||||
|
|
||||||
# 方案 B:开发者环境(可编辑模式开发)
|
|
||||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
|
||||||
# 然后安装 unilabos 和依赖:
|
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
|
||||||
pip install -e .
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
|
|
||||||
# 方案 C:完整安装(仿真/可视化)
|
|
||||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**如何选择?**
|
2. 安装开发版 Uni-Lab-OS:
|
||||||
- **unilabos**:标准安装,适用于生产部署和日常使用(推荐)
|
|
||||||
- **unilabos-env**:开发者使用,支持 `pip install -e .` 可编辑模式,可修改源代码
|
|
||||||
- **unilabos-full**:需要仿真(Gazebo)、可视化(rviz2)或 Jupyter Notebook
|
|
||||||
|
|
||||||
### 2. 克隆仓库(可选,供开发者使用)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 克隆仓库(仅开发或查看示例时需要)
|
# 克隆仓库
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||||
cd Uni-Lab-OS
|
cd Uni-Lab-OS
|
||||||
|
|
||||||
|
# 安装 Uni-Lab-OS
|
||||||
|
pip install .
|
||||||
```
|
```
|
||||||
|
|
||||||
3. 启动 Uni-Lab 系统
|
3. 启动 Uni-Lab 系统:
|
||||||
|
|
||||||
请见[文档-启动样例](https://deepmodeling.github.io/Uni-Lab-OS/boot_examples/index.html)
|
请见[文档-启动样例](https://xuwznln.github.io/Uni-Lab-OS-Doc/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/deepmodeling/Uni-Lab-OS/releases) 页面找到已构建的版本。
|
Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在 [GitHub Releases](https://github.com/dptech-corp/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)。
|
|
||||||
|
|
||||||
## 项目统计
|
## 项目统计
|
||||||
|
|
||||||
@@ -121,4 +77,4 @@ Uni-Lab-OS 使用预构建的 `unilabos_msgs` 进行系统通信。您可以在
|
|||||||
|
|
||||||
## 联系我们
|
## 联系我们
|
||||||
|
|
||||||
- GitHub Issues: [https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- GitHub Issues: [https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
|
|||||||
@@ -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/deepmodeling/Uni-Lab-OS",
|
"repository_url": "https://github.com/用户名/Uni-Lab",
|
||||||
"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
@@ -15,9 +15,6 @@ Python 类设备驱动在完成注册表后可以直接在 Uni-Lab 中使用,
|
|||||||
**示例:**
|
**示例:**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.registry.decorators import device, topic_config
|
|
||||||
|
|
||||||
@device(id="mock_gripper", category=["gripper"], description="Mock Gripper")
|
|
||||||
class MockGripper:
|
class MockGripper:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._position: float = 0.0
|
self._position: float = 0.0
|
||||||
@@ -26,23 +23,19 @@ class MockGripper:
|
|||||||
self._status = "Idle"
|
self._status = "Idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 添加 @topic_config 才会定时广播
|
|
||||||
def position(self) -> float:
|
def position(self) -> float:
|
||||||
return self._position
|
return self._position
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def velocity(self) -> float:
|
def velocity(self) -> float:
|
||||||
return self._velocity
|
return self._velocity
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def torque(self) -> float:
|
def torque(self) -> float:
|
||||||
return self._torque
|
return self._torque
|
||||||
|
|
||||||
# 使用 @topic_config 装饰的属性,接入 Uni-Lab 时会定时对外广播
|
# 会被自动识别的设备属性,接入 Uni-Lab 时会定时对外广播
|
||||||
@property
|
@property
|
||||||
@topic_config(period=2.0) # 可自定义发布周期
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@@ -156,7 +149,7 @@ my_device: # 设备唯一标识符
|
|||||||
|
|
||||||
系统会自动分析您的 Python 驱动类并生成:
|
系统会自动分析您的 Python 驱动类并生成:
|
||||||
|
|
||||||
- `status_types`:从 `@topic_config` 装饰的 `@property` 或方法自动识别状态属性
|
- `status_types`:从 `@property` 装饰的方法自动识别状态属性
|
||||||
- `action_value_mappings`:从类方法自动生成动作映射
|
- `action_value_mappings`:从类方法自动生成动作映射
|
||||||
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
- `init_param_schema`:从 `__init__` 方法分析初始化参数
|
||||||
- `schema`:前端显示用的属性类型定义
|
- `schema`:前端显示用的属性类型定义
|
||||||
@@ -186,9 +179,7 @@ Uni-Lab 设备驱动是一个 Python 类,需要遵循以下结构:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from unilabos.registry.decorators import device, topic_config
|
|
||||||
|
|
||||||
@device(id="my_device", category=["general"], description="My Device")
|
|
||||||
class MyDevice:
|
class MyDevice:
|
||||||
"""设备类文档字符串
|
"""设备类文档字符串
|
||||||
|
|
||||||
@@ -207,9 +198,8 @@ class MyDevice:
|
|||||||
# 初始化硬件连接
|
# 初始化硬件连接
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 必须添加 @topic_config 才会广播
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态(通过 @topic_config 广播)"""
|
"""设备状态(会自动广播)"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
def my_action(self, param: float) -> Dict[str, Any]:
|
def my_action(self, param: float) -> Dict[str, Any]:
|
||||||
@@ -227,61 +217,34 @@ class MyDevice:
|
|||||||
|
|
||||||
## 状态属性 vs 动作方法
|
## 状态属性 vs 动作方法
|
||||||
|
|
||||||
### 状态属性(@property + @topic_config)
|
### 状态属性(@property)
|
||||||
|
|
||||||
状态属性需要同时使用 `@property` 和 `@topic_config` 装饰器才会被识别并定期广播:
|
状态属性会被自动识别并定期广播:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config() # 必须添加,否则不会广播
|
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
"""当前温度"""
|
"""当前温度"""
|
||||||
return self._read_temperature()
|
return self._read_temperature()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config(period=2.0) # 可自定义发布周期(秒)
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态: idle, running, error"""
|
"""设备状态: idle, running, error"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config(name="ready") # 可自定义发布名称
|
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
"""设备是否就绪"""
|
"""设备是否就绪"""
|
||||||
return self._status == "idle"
|
return self._status == "idle"
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以使用普通方法(非 @property)配合 `@topic_config`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@topic_config(period=10.0)
|
|
||||||
def get_sensor_data(self) -> Dict[str, float]:
|
|
||||||
"""获取传感器数据(get_ 前缀会自动去除,发布名为 sensor_data)"""
|
|
||||||
return {"temp": self._temp, "humidity": self._humidity}
|
|
||||||
```
|
|
||||||
|
|
||||||
**`@topic_config` 参数**:
|
|
||||||
|
|
||||||
| 参数 | 类型 | 默认值 | 说明 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `period` | float | 5.0 | 发布周期(秒) |
|
|
||||||
| `print_publish` | bool | 节点默认 | 是否打印发布日志 |
|
|
||||||
| `qos` | int | 10 | QoS 深度 |
|
|
||||||
| `name` | str | None | 自定义发布名称 |
|
|
||||||
|
|
||||||
**发布名称优先级**:`@topic_config(name=...)` > `get_` 前缀去除 > 方法名
|
|
||||||
|
|
||||||
**特点**:
|
**特点**:
|
||||||
|
|
||||||
- 必须使用 `@topic_config` 装饰器
|
- 使用`@property`装饰器
|
||||||
- 支持 `@property` 和普通方法
|
- 只读,不能有参数
|
||||||
- 添加到注册表的 `status_types`
|
- 自动添加到注册表的`status_types`
|
||||||
- 定期发布到 ROS2 topic
|
- 定期发布到 ROS2 topic
|
||||||
|
|
||||||
> **⚠️ 重要:** 仅有 `@property` 装饰器而没有 `@topic_config` 的属性**不会**被广播。这是一个 Breaking Change。
|
|
||||||
|
|
||||||
### 动作方法
|
### 动作方法
|
||||||
|
|
||||||
动作方法是设备可以执行的操作:
|
动作方法是设备可以执行的操作:
|
||||||
@@ -534,7 +497,6 @@ class LiquidHandler:
|
|||||||
self._status = "idle"
|
self._status = "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@@ -924,52 +886,7 @@ class MyDevice:
|
|||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|
||||||
### 1. 使用 `@device` 装饰器标识设备类
|
### 1. 类型注解
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device
|
|
||||||
|
|
||||||
@device(id="my_device", category=["heating"], description="My Heating Device", icon="heater.webp")
|
|
||||||
class MyDevice:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
- `id`:设备唯一标识符,用于注册表匹配
|
|
||||||
- `category`:分类列表,前端用于分组显示
|
|
||||||
- `description`:设备描述
|
|
||||||
- `icon`:图标文件名(可选)
|
|
||||||
|
|
||||||
### 2. 使用 `@topic_config` 声明需要广播的状态
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
# ✓ @property + @topic_config → 会广播
|
|
||||||
@property
|
|
||||||
@topic_config(period=2.0)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self._temp
|
|
||||||
|
|
||||||
# ✓ 普通方法 + @topic_config → 会广播(get_ 前缀自动去除)
|
|
||||||
@topic_config(period=10.0)
|
|
||||||
def get_sensor_data(self) -> Dict[str, float]:
|
|
||||||
return {"temp": self._temp}
|
|
||||||
|
|
||||||
# ✓ 使用 name 参数自定义发布名称
|
|
||||||
@property
|
|
||||||
@topic_config(name="ready")
|
|
||||||
def is_ready(self) -> bool:
|
|
||||||
return self._status == "idle"
|
|
||||||
|
|
||||||
# ✗ 仅有 @property,没有 @topic_config → 不会广播
|
|
||||||
@property
|
|
||||||
def internal_state(self) -> str:
|
|
||||||
return self._state
|
|
||||||
```
|
|
||||||
|
|
||||||
> **注意:** 与 `@property` 连用时,`@topic_config` 必须放在 `@property` 下面。
|
|
||||||
|
|
||||||
### 3. 类型注解
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
@@ -984,7 +901,7 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 文档字符串
|
### 2. 文档字符串
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def method(self, param: float) -> Dict[str, Any]:
|
def method(self, param: float) -> Dict[str, Any]:
|
||||||
@@ -1006,7 +923,7 @@ def method(self, param: float) -> Dict[str, Any]:
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 配置验证
|
### 3. 配置验证
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __init__(self, config: Dict[str, Any]):
|
def __init__(self, config: Dict[str, Any]):
|
||||||
@@ -1020,7 +937,7 @@ def __init__(self, config: Dict[str, Any]):
|
|||||||
self.baudrate = config['baudrate']
|
self.baudrate = config['baudrate']
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 资源清理
|
### 4. 资源清理
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
@@ -1029,7 +946,7 @@ def __del__(self):
|
|||||||
self.connection.close()
|
self.connection.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. 设计前端友好的返回值
|
### 5. 设计前端友好的返回值
|
||||||
|
|
||||||
**记住:返回值会直接显示在 Web 界面**
|
**记住:返回值会直接显示在 Web 界面**
|
||||||
|
|
||||||
|
|||||||
@@ -422,20 +422,18 @@ placeholder_keys:
|
|||||||
|
|
||||||
### status_types
|
### status_types
|
||||||
|
|
||||||
系统会扫描你的 Python 类,从带有 `@topic_config` 装饰器的 `@property` 或方法自动生成这部分:
|
系统会扫描你的 Python 类,从状态方法(property 或 get\_方法)自动生成这部分:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
status_types:
|
status_types:
|
||||||
current_temperature: float # 从 @topic_config 装饰的 @property 或方法
|
current_temperature: float # 从 get_current_temperature() 或 @property current_temperature
|
||||||
is_heating: bool
|
is_heating: bool # 从 get_is_heating() 或 @property is_heating
|
||||||
status: str
|
status: str # 从 get_status() 或 @property status
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意事项**:
|
**注意事项**:
|
||||||
|
|
||||||
- 仅有带 `@topic_config` 装饰器的 `@property` 或方法才会被识别为状态属性
|
- 系统会查找所有 `get_` 开头的方法和 `@property` 装饰的属性
|
||||||
- 没有 `@topic_config` 的 `@property` 不会生成 status_types,也不会广播
|
|
||||||
- `get_` 前缀的方法名会自动去除前缀(如 `get_temperature` → `temperature`)
|
|
||||||
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
- 类型会自动转成相应的类型(如 `str`、`float`、`bool`)
|
||||||
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
- 如果类型是 `Any`、`None` 或未知的,默认使用 `String`
|
||||||
|
|
||||||
@@ -539,13 +537,11 @@ class AdvancedLiquidHandler:
|
|||||||
self._temperature = 25.0
|
self._temperature = 25.0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
"""设备状态"""
|
"""设备状态"""
|
||||||
return self._status
|
return self._status
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
"""当前温度"""
|
"""当前温度"""
|
||||||
return self._temperature
|
return self._temperature
|
||||||
@@ -813,23 +809,21 @@ my_temperature_controller:
|
|||||||
你的设备类需要符合以下要求:
|
你的设备类需要符合以下要求:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.registry.decorators import device, topic_config
|
from unilabos.common.device_base import DeviceBase
|
||||||
|
|
||||||
@device(id="my_device", category=["temperature"], description="My Device")
|
class MyDevice(DeviceBase):
|
||||||
class MyDevice:
|
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
"""初始化,参数会自动分析到 init_param_schema.config"""
|
"""初始化,参数会自动分析到 init_param_schema.config"""
|
||||||
|
super().__init__(config)
|
||||||
self.port = config.get('port', '/dev/ttyUSB0')
|
self.port = config.get('port', '/dev/ttyUSB0')
|
||||||
|
|
||||||
# 状态方法(必须添加 @topic_config 才会生成到 status_types 并广播)
|
# 状态方法(会自动生成到 status_types)
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def status(self):
|
def status(self):
|
||||||
"""返回设备状态"""
|
"""返回设备状态"""
|
||||||
return "idle"
|
return "idle"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@topic_config()
|
|
||||||
def temperature(self):
|
def temperature(self):
|
||||||
"""返回当前温度"""
|
"""返回当前温度"""
|
||||||
return 25.0
|
return 25.0
|
||||||
@@ -1045,34 +1039,7 @@ resource.type # "resource"
|
|||||||
|
|
||||||
### 代码规范
|
### 代码规范
|
||||||
|
|
||||||
1. **使用 `@device` 装饰器标识设备类**
|
1. **始终使用类型注解**
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import device
|
|
||||||
|
|
||||||
@device(id="my_device", category=["heating"], description="My Device")
|
|
||||||
class MyDevice:
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **使用 `@topic_config` 声明广播属性**
|
|
||||||
|
|
||||||
```python
|
|
||||||
from unilabos.registry.decorators import topic_config
|
|
||||||
|
|
||||||
# ✓ 需要广播的状态属性
|
|
||||||
@property
|
|
||||||
@topic_config(period=2.0)
|
|
||||||
def temperature(self) -> float:
|
|
||||||
return self._temp
|
|
||||||
|
|
||||||
# ✗ 仅有 @property 不会广播
|
|
||||||
@property
|
|
||||||
def internal_counter(self) -> int:
|
|
||||||
return self._counter
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **始终使用类型注解**
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✓ 好
|
# ✓ 好
|
||||||
@@ -1084,7 +1051,7 @@ def method(self, resource, device):
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **提供有意义的参数名**
|
2. **提供有意义的参数名**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# ✓ 好 - 清晰的参数名
|
# ✓ 好 - 清晰的参数名
|
||||||
@@ -1096,7 +1063,7 @@ def transfer(self, r1: ResourceSlot, r2: ResourceSlot):
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **使用 Optional 表示可选参数**
|
3. **使用 Optional 表示可选参数**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -1109,7 +1076,7 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **添加详细的文档字符串**
|
4. **添加详细的文档字符串**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def method(
|
def method(
|
||||||
@@ -1129,13 +1096,13 @@ def method(
|
|||||||
pass
|
pass
|
||||||
```
|
```
|
||||||
|
|
||||||
7. **方法命名规范**
|
5. **方法命名规范**
|
||||||
|
|
||||||
- 状态方法使用 `@property` + `@topic_config` 装饰器,或普通方法 + `@topic_config`
|
- 状态方法使用 `@property` 装饰器或 `get_` 前缀
|
||||||
- 动作方法使用动词开头
|
- 动作方法使用动词开头
|
||||||
- 保持命名清晰、一致
|
- 保持命名清晰、一致
|
||||||
|
|
||||||
8. **完善的错误处理**
|
6. **完善的错误处理**
|
||||||
- 实现完善的错误处理
|
- 实现完善的错误处理
|
||||||
- 添加日志记录
|
- 添加日志记录
|
||||||
- 提供有意义的错误信息
|
- 提供有意义的错误信息
|
||||||
|
|||||||
@@ -221,10 +221,10 @@ Laboratory A Laboratory B
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 实验室A
|
# 实验室A
|
||||||
unilab --ak your_ak --sk your_sk --upload_registry
|
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||||
|
|
||||||
# 实验室B
|
# 实验室B
|
||||||
unilab --ak your_ak --sk your_sk --upload_registry
|
unilab --ak your_ak --sk your_sk --upload_registry --use_remote_resource
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,7 +12,3 @@ 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
|
|
||||||
@@ -31,14 +31,6 @@
|
|||||||
|
|
||||||
详细的安装步骤请参考 [安装指南](installation.md)。
|
详细的安装步骤请参考 [安装指南](installation.md)。
|
||||||
|
|
||||||
**选择合适的安装包:**
|
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含组件 |
|
|
||||||
|--------|----------|----------|
|
|
||||||
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
|
|
||||||
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
|
|
||||||
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
|
|
||||||
|
|
||||||
**关键步骤:**
|
**关键步骤:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -46,30 +38,15 @@
|
|||||||
# 下载 Miniforge: https://github.com/conda-forge/miniforge/releases
|
# 下载 Miniforge: https://github.com/conda-forge/miniforge/releases
|
||||||
|
|
||||||
# 2. 创建 Conda 环境
|
# 2. 创建 Conda 环境
|
||||||
mamba create -n unilab python=3.11.14
|
mamba create -n unilab python=3.11.11
|
||||||
|
|
||||||
# 3. 激活环境
|
# 3. 激活环境
|
||||||
mamba activate unilab
|
mamba activate unilab
|
||||||
|
|
||||||
# 4. 安装 Uni-Lab-OS(选择其一)
|
# 4. 安装 Uni-Lab-OS
|
||||||
|
|
||||||
# 方案 A:标准安装(推荐大多数用户)
|
|
||||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||||
|
|
||||||
# 方案 B:开发者环境(可编辑模式开发)
|
|
||||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
|
||||||
pip install -e /path/to/Uni-Lab-OS # 可编辑安装
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt # 安装 pip 依赖
|
|
||||||
|
|
||||||
# 方案 C:完整版(仿真/可视化)
|
|
||||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**选择建议:**
|
|
||||||
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
|
|
||||||
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
|
|
||||||
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
|
|
||||||
|
|
||||||
#### 1.2 验证安装
|
#### 1.2 验证安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -439,9 +416,6 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
1. 访问 Web 界面,进入"仪器耗材"模块
|
1. 访问 Web 界面,进入"仪器耗材"模块
|
||||||
2. 在"仪器设备"区域找到并添加上述设备
|
2. 在"仪器设备"区域找到并添加上述设备
|
||||||
3. 在"物料耗材"区域找到并添加容器
|
3. 在"物料耗材"区域找到并添加容器
|
||||||
4. 在workstation中配置protocol_type包含PumpTransferProtocol
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -452,9 +426,8 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
|||||||
**操作步骤:**
|
**操作步骤:**
|
||||||
|
|
||||||
1. 将两个 `container` 拖拽到 `workstation` 中
|
1. 将两个 `container` 拖拽到 `workstation` 中
|
||||||
2. 将 `virtual_multiway_valve` 拖拽到 `workstation` 中
|
2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||||
3. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
3. 在画布上连接它们(建立父子关系)
|
||||||
4. 在画布上连接它们(建立父子关系)
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -795,43 +768,7 @@ Waiting for host service...
|
|||||||
|
|
||||||
详细的设备驱动编写指南请参考 [添加设备驱动](../developer_guide/add_device.md)。
|
详细的设备驱动编写指南请参考 [添加设备驱动](../developer_guide/add_device.md)。
|
||||||
|
|
||||||
#### 9.1 开发环境准备
|
#### 9.1 为什么需要自定义设备?
|
||||||
|
|
||||||
**推荐使用 `unilabos-env` + `pip install -e .` + `uv pip install`** 进行设备开发:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 创建环境并安装 unilabos-env(ROS2 + conda 依赖 + uv)
|
|
||||||
mamba create -n unilab python=3.11.14
|
|
||||||
conda activate unilab
|
|
||||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
|
||||||
|
|
||||||
# 2. 克隆代码
|
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
|
||||||
cd Uni-Lab-OS
|
|
||||||
|
|
||||||
# 3. 以可编辑模式安装(推荐使用脚本,自动检测中文环境)
|
|
||||||
python scripts/dev_install.py
|
|
||||||
|
|
||||||
# 或手动安装:
|
|
||||||
pip install -e .
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**为什么使用这种方式?**
|
|
||||||
- `unilabos-env` 提供 ROS2 核心组件和 uv(通过 conda 安装,避免编译)
|
|
||||||
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
|
|
||||||
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
|
|
||||||
- 使用 `uv` 替代 `pip`,安装速度更快
|
|
||||||
- 可编辑模式:代码修改**立即生效**,无需重新安装
|
|
||||||
|
|
||||||
**如果安装失败或速度太慢**,可以手动执行(使用清华镜像):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 9.2 为什么需要自定义设备?
|
|
||||||
|
|
||||||
Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要集成:
|
Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要集成:
|
||||||
|
|
||||||
@@ -840,7 +777,7 @@ Uni-Lab-OS 内置了常见设备,但您的实验室可能有特殊设备需要
|
|||||||
- 特殊的实验流程
|
- 特殊的实验流程
|
||||||
- 第三方设备集成
|
- 第三方设备集成
|
||||||
|
|
||||||
#### 9.3 创建 Python 包
|
#### 9.2 创建 Python 包
|
||||||
|
|
||||||
为了方便开发和管理,建议为您的实验室创建独立的 Python 包。
|
为了方便开发和管理,建议为您的实验室创建独立的 Python 包。
|
||||||
|
|
||||||
@@ -877,7 +814,7 @@ touch my_lab_devices/my_lab_devices/__init__.py
|
|||||||
touch my_lab_devices/my_lab_devices/devices/__init__.py
|
touch my_lab_devices/my_lab_devices/devices/__init__.py
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 9.4 创建 setup.py
|
#### 9.3 创建 setup.py
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# my_lab_devices/setup.py
|
# my_lab_devices/setup.py
|
||||||
@@ -908,7 +845,7 @@ setup(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 9.5 开发安装
|
#### 9.4 开发安装
|
||||||
|
|
||||||
使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效:
|
使用 `-e` 参数进行可编辑安装,这样代码修改后立即生效:
|
||||||
|
|
||||||
@@ -923,7 +860,7 @@ pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
|||||||
- 方便调试和测试
|
- 方便调试和测试
|
||||||
- 支持版本控制(git)
|
- 支持版本控制(git)
|
||||||
|
|
||||||
#### 9.6 编写设备驱动
|
#### 9.5 编写设备驱动
|
||||||
|
|
||||||
创建设备驱动文件:
|
创建设备驱动文件:
|
||||||
|
|
||||||
@@ -1064,7 +1001,7 @@ class MyPump:
|
|||||||
- **返回 Dict**:所有动作方法返回字典类型
|
- **返回 Dict**:所有动作方法返回字典类型
|
||||||
- **文档字符串**:详细说明参数和功能
|
- **文档字符串**:详细说明参数和功能
|
||||||
|
|
||||||
#### 9.7 测试设备驱动
|
#### 9.6 测试设备驱动
|
||||||
|
|
||||||
创建简单的测试脚本:
|
创建简单的测试脚本:
|
||||||
|
|
||||||
@@ -1870,7 +1807,7 @@ unilab --ak your_ak --sk your_sk -g graph.json \
|
|||||||
|
|
||||||
#### 14.5 社区支持
|
#### 14.5 社区支持
|
||||||
|
|
||||||
- **GitHub Issues**:[https://github.com/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- **GitHub Issues**:[https://github.com/dptech-corp/Uni-Lab-OS/issues](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
- **官方网站**:[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ Uni-Lab 使用 `ResourceDictInstance.get_resource_instance_from_dict()` 方法
|
|||||||
### 使用示例
|
### 使用示例
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from unilabos.resources.resource_tracker import ResourceDictInstance
|
from unilabos.ros.nodes.resource_tracker import ResourceDictInstance
|
||||||
|
|
||||||
# 旧格式节点
|
# 旧格式节点
|
||||||
old_format_node = {
|
old_format_node = {
|
||||||
@@ -857,4 +857,4 @@ class ResourceDictPosition(BaseModel):
|
|||||||
- 在 Web 界面中使用模板创建
|
- 在 Web 界面中使用模板创建
|
||||||
- 参考示例文件:`test/experiments/` 目录
|
- 参考示例文件:`test/experiments/` 目录
|
||||||
- 查看 ResourceDict 源码了解完整定义
|
- 查看 ResourceDict 源码了解完整定义
|
||||||
- [GitHub 讨论区](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
- [GitHub 讨论区](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 275 KiB |
@@ -13,26 +13,15 @@
|
|||||||
- 开发者需要 Git 和基本的 Python 开发知识
|
- 开发者需要 Git 和基本的 Python 开发知识
|
||||||
- 自定义 msgs 需要 GitHub 账号
|
- 自定义 msgs 需要 GitHub 账号
|
||||||
|
|
||||||
## 安装包选择
|
|
||||||
|
|
||||||
Uni-Lab-OS 提供三个安装包版本,根据您的需求选择:
|
|
||||||
|
|
||||||
| 安装包 | 适用场景 | 包含组件 | 磁盘占用 |
|
|
||||||
|--------|----------|----------|----------|
|
|
||||||
| **unilabos** | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | ~2-3 GB |
|
|
||||||
| **unilabos-env** | 开发者环境(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | ~2 GB |
|
|
||||||
| **unilabos-full** | 仿真可视化、完整功能体验 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | ~8-10 GB |
|
|
||||||
|
|
||||||
## 安装方式选择
|
## 安装方式选择
|
||||||
|
|
||||||
根据您的使用场景,选择合适的安装方式:
|
根据您的使用场景,选择合适的安装方式:
|
||||||
|
|
||||||
| 安装方式 | 适用人群 | 推荐安装包 | 特点 | 安装时间 |
|
| 安装方式 | 适用人群 | 特点 | 安装时间 |
|
||||||
| ---------------------- | -------------------- | ----------------- | ------------------------------ | ---------------------------- |
|
| ---------------------- | -------------------- | ------------------------------ | ---------------------------- |
|
||||||
| **方式一:一键安装** | 快速体验、演示 | 预打包环境 | 离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
| **方式一:一键安装** | 实验室用户、快速体验 | 预打包环境,离线可用,无需配置 | 5-10 分钟 (网络良好的情况下) |
|
||||||
| **方式二:手动安装** | **大多数用户** | `unilabos` | 完整功能,开箱即用 | 10-20 分钟 |
|
| **方式二:手动安装** | 标准用户、生产环境 | 灵活配置,版本可控 | 10-20 分钟 |
|
||||||
| **方式三:开发者安装** | 开发者、需要修改源码 | `unilabos-env` | 可编辑模式,支持自定义开发 | 20-30 分钟 |
|
| **方式三:开发者安装** | 开发者、需要修改源码 | 可编辑模式,支持自定义 msgs | 20-30 分钟 |
|
||||||
| **仿真/可视化** | 仿真测试、可视化调试 | `unilabos-full` | 含 Gazebo、rviz2、MoveIt | 30-60 分钟 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -48,7 +37,7 @@ Uni-Lab-OS 提供三个安装包版本,根据您的需求选择:
|
|||||||
|
|
||||||
#### 第一步:下载预打包环境
|
#### 第一步:下载预打包环境
|
||||||
|
|
||||||
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/deepmodeling/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
1. 访问 [GitHub Actions - Conda Pack Build](https://github.com/dptech-corp/Uni-Lab-OS/actions/workflows/conda-pack-build.yml)
|
||||||
|
|
||||||
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
2. 选择最新的成功构建记录(绿色勾号 ✓)
|
||||||
|
|
||||||
@@ -155,38 +144,17 @@ bash Miniforge3-$(uname)-$(uname -m).sh
|
|||||||
使用以下命令创建 Uni-Lab 专用环境:
|
使用以下命令创建 Uni-Lab 专用环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mamba create -n unilab python=3.11.14 # 目前ros2组件依赖版本大多为3.11.14
|
mamba create -n unilab python=3.11.11 # 目前ros2组件依赖版本大多为3.11.11
|
||||||
mamba activate unilab
|
mamba activate unilab
|
||||||
|
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||||
# 选择安装包(三选一):
|
|
||||||
|
|
||||||
# 方案 A:标准安装(推荐大多数用户)
|
|
||||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
|
||||||
|
|
||||||
# 方案 B:开发者环境(可编辑模式开发)
|
|
||||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
|
||||||
# 然后安装 unilabos 和 pip 依赖:
|
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git && cd Uni-Lab-OS
|
|
||||||
pip install -e .
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
|
|
||||||
# 方案 C:完整版(含仿真和可视化工具)
|
|
||||||
mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数说明**:
|
**参数说明**:
|
||||||
|
|
||||||
- `-n unilab`: 创建名为 "unilab" 的环境
|
- `-n unilab`: 创建名为 "unilab" 的环境
|
||||||
- `uni-lab::unilabos`: 安装 unilabos 完整包,开箱即用(推荐)
|
- `uni-lab::unilabos`: 从 uni-lab channel 安装 unilabos 包
|
||||||
- `uni-lab::unilabos-env`: 仅安装环境依赖,适合开发者使用 `pip install -e .`
|
|
||||||
- `uni-lab::unilabos-full`: 安装完整包(含 ROS2 Desktop、Gazebo、MoveIt 等)
|
|
||||||
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
- `-c robostack-staging -c conda-forge`: 添加额外的软件源
|
||||||
|
|
||||||
**包选择建议**:
|
|
||||||
- **日常使用/生产部署**:安装 `unilabos`(推荐,完整功能,开箱即用)
|
|
||||||
- **开发者**:安装 `unilabos-env`,然后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖,再 `pip install -e .` 进行可编辑安装
|
|
||||||
- **仿真/可视化**:安装 `unilabos-full`(Gazebo、rviz2、MoveIt)
|
|
||||||
|
|
||||||
**如果遇到网络问题**,可以使用清华镜像源加速下载:
|
**如果遇到网络问题**,可以使用清华镜像源加速下载:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -195,14 +163,8 @@ mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/m
|
|||||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
|
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
|
||||||
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
mamba config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
|
||||||
|
|
||||||
# 然后重新执行安装命令(推荐标准安装)
|
# 然后重新执行安装命令
|
||||||
mamba create -n unilab uni-lab::unilabos -c robostack-staging
|
mamba create -n unilab uni-lab::unilabos -c robostack-staging
|
||||||
|
|
||||||
# 或完整版(仿真/可视化)
|
|
||||||
mamba create -n unilab uni-lab::unilabos-full -c robostack-staging
|
|
||||||
|
|
||||||
# pip 安装时使用清华镜像(开发者安装时使用)
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第三步:激活环境
|
### 第三步:激活环境
|
||||||
@@ -227,13 +189,13 @@ conda activate unilab
|
|||||||
### 第一步:克隆仓库
|
### 第一步:克隆仓库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
git clone https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||||
cd Uni-Lab-OS
|
cd Uni-Lab-OS
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您需要贡献代码,建议先 Fork 仓库:
|
如果您需要贡献代码,建议先 Fork 仓库:
|
||||||
|
|
||||||
1. 访问 https://github.com/deepmodeling/Uni-Lab-OS
|
1. 访问 https://github.com/dptech-corp/Uni-Lab-OS
|
||||||
2. 点击右上角的 "Fork" 按钮
|
2. 点击右上角的 "Fork" 按钮
|
||||||
3. Clone 您的 Fork 版本:
|
3. Clone 您的 Fork 版本:
|
||||||
```bash
|
```bash
|
||||||
@@ -241,87 +203,58 @@ cd Uni-Lab-OS
|
|||||||
cd Uni-Lab-OS
|
cd Uni-Lab-OS
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第二步:安装开发环境(unilabos-env)
|
### 第二步:安装基础环境
|
||||||
|
|
||||||
**重要**:开发者请使用 `unilabos-env` 包,它专为开发者设计:
|
**推荐方式**:先通过**方式一(一键安装)**或**方式二(手动安装)**完成基础环境的安装,这将包含所有必需的依赖项(ROS2、msgs 等)。
|
||||||
- 包含 ROS2 核心组件和消息包(ros-humble-ros-core、std-msgs、geometry-msgs 等)
|
|
||||||
- 包含 transforms3d、cv-bridge、tf2 等 conda 依赖
|
#### 选项 A:通过一键安装(推荐)
|
||||||
- 包含 `uv` 工具,用于快速安装 pip 依赖
|
|
||||||
- **不包含** pip 依赖和 unilabos 包(由 `pip install -e .` 和 `uv pip install` 安装)
|
参考上文"方式一:一键安装",完成基础环境的安装后,激活环境:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建并激活环境
|
|
||||||
mamba create -n unilab python=3.11.14
|
|
||||||
conda activate unilab
|
conda activate unilab
|
||||||
|
|
||||||
# 安装开发者环境包(ROS2 + conda 依赖 + uv)
|
|
||||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 第三步:安装 pip 依赖和可编辑模式安装
|
#### 选项 B:通过手动安装
|
||||||
|
|
||||||
克隆代码并安装依赖:
|
参考上文"方式二:手动安装",创建并安装环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mamba create -n unilab python=3.11.11
|
||||||
|
conda activate unilab
|
||||||
|
mamba install -n unilab uni-lab::unilabos -c robostack-staging -c conda-forge
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:这会安装包括 Python 3.11.11、ROS2 Humble、ros-humble-unilabos-msgs 和所有必需依赖
|
||||||
|
|
||||||
|
### 第三步:切换到开发版本
|
||||||
|
|
||||||
|
现在你已经有了一个完整可用的 Uni-Lab 环境,接下来将 unilabos 包切换为开发版本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 确保环境已激活
|
# 确保环境已激活
|
||||||
conda activate unilab
|
conda activate unilab
|
||||||
|
|
||||||
# 克隆仓库(如果还未克隆)
|
# 卸载 pip 安装的 unilabos(保留所有 conda 依赖)
|
||||||
git clone https://github.com/deepmodeling/Uni-Lab-OS.git
|
pip uninstall unilabos -y
|
||||||
cd Uni-Lab-OS
|
|
||||||
|
|
||||||
# 切换到 dev 分支(可选)
|
# 克隆 dev 分支(如果还未克隆)
|
||||||
|
cd /path/to/your/workspace
|
||||||
|
git clone -b dev https://github.com/dptech-corp/Uni-Lab-OS.git
|
||||||
|
# 或者如果已经克隆,切换到 dev 分支
|
||||||
|
cd Uni-Lab-OS
|
||||||
git checkout dev
|
git checkout dev
|
||||||
git pull
|
git pull
|
||||||
```
|
|
||||||
|
|
||||||
**推荐:使用安装脚本**(自动检测中文环境,使用 uv 加速):
|
# 以可编辑模式安装开发版 unilabos
|
||||||
|
|
||||||
```bash
|
|
||||||
# 自动检测中文环境,如果是中文系统则使用清华镜像
|
|
||||||
python scripts/dev_install.py
|
|
||||||
|
|
||||||
# 或者手动指定:
|
|
||||||
python scripts/dev_install.py --china # 强制使用清华镜像
|
|
||||||
python scripts/dev_install.py --no-mirror # 强制使用 PyPI
|
|
||||||
python scripts/dev_install.py --skip-deps # 跳过 pip 依赖安装
|
|
||||||
python scripts/dev_install.py --use-pip # 使用 pip 而非 uv
|
|
||||||
```
|
|
||||||
|
|
||||||
**手动安装**(如果脚本安装失败或速度太慢):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 安装 unilabos(可编辑模式)
|
|
||||||
pip install -e .
|
|
||||||
|
|
||||||
# 2. 使用 uv 安装 pip 依赖(推荐,速度更快)
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
|
|
||||||
# 国内用户使用清华镜像:
|
|
||||||
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
pip install -e . -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||||
uv pip install -r unilabos/utils/requirements.txt -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**注意**:
|
**参数说明**:
|
||||||
- `uv` 已包含在 `unilabos-env` 中,无需单独安装
|
|
||||||
- `unilabos/utils/requirements.txt` 包含运行 unilabos 所需的所有 pip 依赖
|
|
||||||
- 部分特殊包(如 pylabrobot)会在运行时由 unilabos 自动检测并安装
|
|
||||||
|
|
||||||
**为什么使用可编辑模式?**
|
- `-e`: editable mode(可编辑模式),代码修改立即生效,无需重新安装
|
||||||
|
- `-i`: 使用清华镜像源加速下载
|
||||||
- `-e` (editable mode):代码修改**立即生效**,无需重新安装
|
- `pip uninstall unilabos`: 只卸载 pip 安装的 unilabos 包,不影响 conda 安装的其他依赖(如 ROS2、msgs 等)
|
||||||
- 适合开发调试:修改代码后直接运行测试
|
|
||||||
- 与 `unilabos-env` 配合:环境依赖由 conda 管理,unilabos 代码由 pip 管理
|
|
||||||
|
|
||||||
**验证安装**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查 unilabos 版本
|
|
||||||
python -c "import unilabos; print(unilabos.__version__)"
|
|
||||||
|
|
||||||
# 检查安装位置(应该指向你的代码目录)
|
|
||||||
pip show unilabos | grep Location
|
|
||||||
```
|
|
||||||
|
|
||||||
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
### 第四步:安装或自定义 ros-humble-unilabos-msgs(可选)
|
||||||
|
|
||||||
@@ -384,6 +317,45 @@ unilab --help
|
|||||||
|
|
||||||
如果所有命令都正常输出,说明开发环境配置成功!
|
如果所有命令都正常输出,说明开发环境配置成功!
|
||||||
|
|
||||||
|
### 开发工具推荐
|
||||||
|
|
||||||
|
#### IDE
|
||||||
|
|
||||||
|
- **PyCharm Professional**: 强大的 Python IDE,支持远程调试
|
||||||
|
- **VS Code**: 轻量级,配合 Python 扩展使用
|
||||||
|
- **Vim/Emacs**: 适合终端开发
|
||||||
|
|
||||||
|
#### 推荐的 VS Code 扩展
|
||||||
|
|
||||||
|
- Python
|
||||||
|
- Pylance
|
||||||
|
- ROS
|
||||||
|
- URDF
|
||||||
|
- YAML
|
||||||
|
|
||||||
|
#### 调试工具
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装调试工具
|
||||||
|
pip install ipdb pytest pytest-cov -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||||
|
|
||||||
|
# 代码质量检查
|
||||||
|
pip install black flake8 mypy -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置 pre-commit 钩子(可选)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 pre-commit
|
||||||
|
pip install pre-commit -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||||
|
|
||||||
|
# 设置钩子
|
||||||
|
pre-commit install
|
||||||
|
|
||||||
|
# 手动运行检查
|
||||||
|
pre-commit run --all-files
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 验证安装
|
## 验证安装
|
||||||
@@ -531,45 +503,7 @@ cd $CONDA_PREFIX/envs/unilab
|
|||||||
|
|
||||||
### 问题 8: 环境很大,有办法减小吗?
|
### 问题 8: 环境很大,有办法减小吗?
|
||||||
|
|
||||||
**解决方案**:
|
**解决方案**: 预打包的环境包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。如果空间有限,考虑使用方式二手动安装,只安装需要的组件。
|
||||||
|
|
||||||
1. **使用 `unilabos` 标准版**(推荐大多数用户):
|
|
||||||
```bash
|
|
||||||
mamba install uni-lab::unilabos -c robostack-staging -c conda-forge
|
|
||||||
```
|
|
||||||
标准版包含完整功能,环境大小约 2-3GB(相比完整版的 8-10GB)。
|
|
||||||
|
|
||||||
2. **使用 `unilabos-env` 开发者版**(最小化):
|
|
||||||
```bash
|
|
||||||
mamba install uni-lab::unilabos-env -c robostack-staging -c conda-forge
|
|
||||||
# 然后手动安装依赖
|
|
||||||
pip install -e .
|
|
||||||
uv pip install -r unilabos/utils/requirements.txt
|
|
||||||
```
|
|
||||||
开发者版只包含环境依赖,体积最小约 2GB。
|
|
||||||
|
|
||||||
3. **按需安装额外组件**:
|
|
||||||
如果后续需要特定功能,可以单独安装:
|
|
||||||
```bash
|
|
||||||
# 需要 Jupyter
|
|
||||||
mamba install jupyter jupyros
|
|
||||||
|
|
||||||
# 需要可视化
|
|
||||||
mamba install matplotlib opencv
|
|
||||||
|
|
||||||
# 需要仿真(注意:这会安装大量依赖)
|
|
||||||
mamba install ros-humble-gazebo-ros
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **预打包环境问题**:
|
|
||||||
预打包环境(方式一)包含所有依赖,通常较大(压缩后 2-5GB)。这是为了确保离线安装和完整功能。
|
|
||||||
|
|
||||||
**包选择建议**:
|
|
||||||
| 需求 | 推荐包 | 预估大小 |
|
|
||||||
|------|--------|----------|
|
|
||||||
| 日常使用/生产部署 | `unilabos` | ~2-3 GB |
|
|
||||||
| 开发调试(可编辑模式) | `unilabos-env` | ~2 GB |
|
|
||||||
| 仿真/可视化 | `unilabos-full` | ~8-10 GB |
|
|
||||||
|
|
||||||
### 问题 9: 如何更新到最新版本?
|
### 问题 9: 如何更新到最新版本?
|
||||||
|
|
||||||
@@ -608,15 +542,14 @@ mamba update ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-f
|
|||||||
## 需要帮助?
|
## 需要帮助?
|
||||||
|
|
||||||
- **故障排查**: 查看更详细的故障排查信息
|
- **故障排查**: 查看更详细的故障排查信息
|
||||||
- **GitHub Issues**: [报告问题](https://github.com/deepmodeling/Uni-Lab-OS/issues)
|
- **GitHub Issues**: [报告问题](https://github.com/dptech-corp/Uni-Lab-OS/issues)
|
||||||
- **开发者文档**: 查看开发者指南获取更多技术细节
|
- **开发者文档**: 查看开发者指南获取更多技术细节
|
||||||
- **社区讨论**: [GitHub Discussions](https://github.com/deepmodeling/Uni-Lab-OS/discussions)
|
- **社区讨论**: [GitHub Discussions](https://github.com/dptech-corp/Uni-Lab-OS/discussions)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**提示**:
|
**提示**:
|
||||||
|
|
||||||
- **大多数用户**推荐使用方式二(手动安装)的 `unilabos` 标准版
|
- 生产环境推荐使用方式二(手动安装)的稳定版本
|
||||||
- **开发者**推荐使用方式三(开发者安装),安装 `unilabos-env` 后使用 `uv pip install -r unilabos/utils/requirements.txt` 安装依赖
|
- 开发和测试推荐使用方式三(开发者安装)
|
||||||
- **仿真/可视化**推荐安装 `unilabos-full` 完整版
|
- 快速体验和演示推荐使用方式一(一键安装)
|
||||||
- **快速体验和演示**推荐使用方式一(一键安装)
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ options:
|
|||||||
--is_slave Run the backend as slave node (without host privileges).
|
--is_slave Run the backend as slave node (without host privileges).
|
||||||
--slave_no_host Skip waiting for host service in slave mode
|
--slave_no_host Skip waiting for host service in slave mode
|
||||||
--upload_registry Upload registry information when starting unilab
|
--upload_registry Upload registry information when starting unilab
|
||||||
|
--use_remote_resource Use remote resources when starting unilab
|
||||||
--config CONFIG Configuration file path, supports .py format Python config files
|
--config CONFIG Configuration file path, supports .py format Python config files
|
||||||
--port PORT Port for web service information page
|
--port PORT Port for web service information page
|
||||||
--disable_browser Disable opening information page on startup
|
--disable_browser Disable opening information page on startup
|
||||||
@@ -84,7 +85,7 @@ Uni-Lab 的启动过程分为以下几个阶段:
|
|||||||
支持两种方式:
|
支持两种方式:
|
||||||
|
|
||||||
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
- **本地文件**:使用 `-g` 指定图谱文件(支持 JSON 和 GraphML 格式)
|
||||||
- **远程资源**:不指定本地文件即可
|
- **远程资源**:使用 `--use_remote_resource` 从云端获取
|
||||||
|
|
||||||
### 7. 注册表构建
|
### 7. 注册表构建
|
||||||
|
|
||||||
@@ -195,7 +196,7 @@ unilab --config path/to/your/config.py
|
|||||||
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
unilab --ak your_ak --sk your_sk -g path/to/graph.json --upload_registry
|
||||||
|
|
||||||
# 使用远程资源启动
|
# 使用远程资源启动
|
||||||
unilab --ak your_ak --sk your_sk
|
unilab --ak your_ak --sk your_sk --use_remote_resource
|
||||||
|
|
||||||
# 更新注册表
|
# 更新注册表
|
||||||
unilab --ak your_ak --sk your_sk --complete_registry
|
unilab --ak your_ak --sk your_sk --complete_registry
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: ros-humble-unilabos-msgs
|
name: ros-humble-unilabos-msgs
|
||||||
version: 0.10.19
|
version: 0.10.12
|
||||||
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/deepmodeling/Uni-Lab-OS
|
repository: https://github.com/dptech-corp/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."
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ requirements:
|
|||||||
build:
|
build:
|
||||||
- ${{ compiler('cxx') }}
|
- ${{ compiler('cxx') }}
|
||||||
- ${{ compiler('c') }}
|
- ${{ compiler('c') }}
|
||||||
- python ==3.11.14
|
- python ==3.11.11
|
||||||
- numpy
|
- numpy
|
||||||
- if: build_platform != target_platform
|
- if: build_platform != target_platform
|
||||||
then:
|
then:
|
||||||
@@ -63,14 +63,14 @@ requirements:
|
|||||||
- robostack-staging::ros-humble-rosidl-default-generators
|
- robostack-staging::ros-humble-rosidl-default-generators
|
||||||
- robostack-staging::ros-humble-std-msgs
|
- robostack-staging::ros-humble-std-msgs
|
||||||
- robostack-staging::ros-humble-geometry-msgs
|
- robostack-staging::ros-humble-geometry-msgs
|
||||||
- robostack-staging::ros2-distro-mutex=0.7
|
- robostack-staging::ros2-distro-mutex=0.6
|
||||||
run:
|
run:
|
||||||
- robostack-staging::ros-humble-action-msgs
|
- robostack-staging::ros-humble-action-msgs
|
||||||
- robostack-staging::ros-humble-ros-workspace
|
- robostack-staging::ros-humble-ros-workspace
|
||||||
- robostack-staging::ros-humble-rosidl-default-runtime
|
- robostack-staging::ros-humble-rosidl-default-runtime
|
||||||
- robostack-staging::ros-humble-std-msgs
|
- robostack-staging::ros-humble-std-msgs
|
||||||
- robostack-staging::ros-humble-geometry-msgs
|
- robostack-staging::ros-humble-geometry-msgs
|
||||||
- robostack-staging::ros2-distro-mutex=0.7
|
- robostack-staging::ros2-distro-mutex=0.6
|
||||||
- if: osx and x86_64
|
- if: osx and x86_64
|
||||||
then:
|
then:
|
||||||
- __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
- __osx >=${{ MACOSX_DEPLOYMENT_TARGET|default('10.14') }}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package:
|
package:
|
||||||
name: unilabos
|
name: unilabos
|
||||||
version: "0.10.19"
|
version: "0.10.12"
|
||||||
|
|
||||||
source:
|
source:
|
||||||
path: ../..
|
path: ../..
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Verification:
|
|||||||
-------------
|
-------------
|
||||||
|
|
||||||
The verify_installation.py script will check:
|
The verify_installation.py script will check:
|
||||||
- Python version (3.11.14)
|
- Python version (3.11.11)
|
||||||
- ROS2 rclpy installation
|
- ROS2 rclpy installation
|
||||||
- UniLabOS installation and dependencies
|
- UniLabOS installation and dependencies
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ Build Information:
|
|||||||
|
|
||||||
Branch: {branch}
|
Branch: {branch}
|
||||||
Platform: {platform}
|
Platform: {platform}
|
||||||
Python: 3.11.14
|
Python: 3.11.11
|
||||||
Date: {build_date}
|
Date: {build_date}
|
||||||
|
|
||||||
Troubleshooting:
|
Troubleshooting:
|
||||||
@@ -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/deepmodeling/Uni-Lab-OS/issues
|
- Issues: https://github.com/dptech-corp/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/deepmodeling/Uni-Lab-OS
|
Repository: https://github.com/dptech-corp/Uni-Lab-OS
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return readme
|
return readme
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Development installation script for UniLabOS.
|
|
||||||
Auto-detects Chinese locale and uses appropriate mirror.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/dev_install.py
|
|
||||||
python scripts/dev_install.py --no-mirror # Force no mirror
|
|
||||||
python scripts/dev_install.py --china # Force China mirror
|
|
||||||
python scripts/dev_install.py --skip-deps # Skip pip dependencies installation
|
|
||||||
|
|
||||||
Flow:
|
|
||||||
1. pip install -e . (install unilabos in editable mode)
|
|
||||||
2. Detect Chinese locale
|
|
||||||
3. Use uv to install pip dependencies from requirements.txt
|
|
||||||
4. Special packages (like pylabrobot) are handled by environment_check.py at runtime
|
|
||||||
"""
|
|
||||||
|
|
||||||
import locale
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Tsinghua mirror URL
|
|
||||||
TSINGHUA_MIRROR = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"
|
|
||||||
|
|
||||||
|
|
||||||
def is_chinese_locale() -> bool:
|
|
||||||
"""
|
|
||||||
Detect if system is in Chinese locale.
|
|
||||||
Same logic as EnvironmentChecker._is_chinese_locale()
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
lang = locale.getdefaultlocale()[0]
|
|
||||||
if lang and ("zh" in lang.lower() or "chinese" in lang.lower()):
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(cmd: list, description: str, retry: int = 2) -> bool:
|
|
||||||
"""Run command with retry support."""
|
|
||||||
print(f"[INFO] {description}")
|
|
||||||
print(f"[CMD] {' '.join(cmd)}")
|
|
||||||
|
|
||||||
for attempt in range(retry + 1):
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, check=True, timeout=600)
|
|
||||||
print(f"[OK] {description}")
|
|
||||||
return True
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
if attempt < retry:
|
|
||||||
print(f"[WARN] Attempt {attempt + 1} failed, retrying...")
|
|
||||||
else:
|
|
||||||
print(f"[ERROR] {description} failed: {e}")
|
|
||||||
return False
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print(f"[ERROR] {description} timed out")
|
|
||||||
return False
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def install_editable(project_root: Path, use_mirror: bool) -> bool:
|
|
||||||
"""Install unilabos in editable mode using pip."""
|
|
||||||
cmd = [sys.executable, "-m", "pip", "install", "-e", str(project_root)]
|
|
||||||
if use_mirror:
|
|
||||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
|
||||||
|
|
||||||
return run_command(cmd, "Installing unilabos in editable mode")
|
|
||||||
|
|
||||||
|
|
||||||
def install_requirements_uv(requirements_file: Path, use_mirror: bool) -> bool:
|
|
||||||
"""Install pip dependencies using uv (installed via conda-forge::uv)."""
|
|
||||||
cmd = ["uv", "pip", "install", "-r", str(requirements_file)]
|
|
||||||
if use_mirror:
|
|
||||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
|
||||||
|
|
||||||
return run_command(cmd, "Installing pip dependencies with uv", retry=2)
|
|
||||||
|
|
||||||
|
|
||||||
def install_requirements_pip(requirements_file: Path, use_mirror: bool) -> bool:
|
|
||||||
"""Fallback: Install pip dependencies using pip."""
|
|
||||||
cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
|
||||||
if use_mirror:
|
|
||||||
cmd.extend(["-i", TSINGHUA_MIRROR])
|
|
||||||
|
|
||||||
return run_command(cmd, "Installing pip dependencies with pip", retry=2)
|
|
||||||
|
|
||||||
|
|
||||||
def check_uv_available() -> bool:
|
|
||||||
"""Check if uv is available (installed via conda-forge::uv)."""
|
|
||||||
try:
|
|
||||||
subprocess.run(["uv", "--version"], capture_output=True, check=True)
|
|
||||||
return True
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="Development installation script for UniLabOS")
|
|
||||||
parser.add_argument("--china", action="store_true", help="Force use China mirror (Tsinghua)")
|
|
||||||
parser.add_argument("--no-mirror", action="store_true", help="Force use default PyPI (no mirror)")
|
|
||||||
parser.add_argument(
|
|
||||||
"--skip-deps", action="store_true", help="Skip pip dependencies installation (only install unilabos)"
|
|
||||||
)
|
|
||||||
parser.add_argument("--use-pip", action="store_true", help="Use pip instead of uv for dependencies")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Determine project root
|
|
||||||
script_dir = Path(__file__).parent
|
|
||||||
project_root = script_dir.parent
|
|
||||||
requirements_file = project_root / "unilabos" / "utils" / "requirements.txt"
|
|
||||||
|
|
||||||
if not (project_root / "setup.py").exists():
|
|
||||||
print(f"[ERROR] setup.py not found in {project_root}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("UniLabOS Development Installation")
|
|
||||||
print("=" * 60)
|
|
||||||
print(f"Project root: {project_root}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Determine mirror usage based on locale
|
|
||||||
if args.no_mirror:
|
|
||||||
use_mirror = False
|
|
||||||
print("[INFO] Mirror disabled by --no-mirror flag")
|
|
||||||
elif args.china:
|
|
||||||
use_mirror = True
|
|
||||||
print("[INFO] China mirror enabled by --china flag")
|
|
||||||
else:
|
|
||||||
use_mirror = is_chinese_locale()
|
|
||||||
if use_mirror:
|
|
||||||
print("[INFO] Chinese locale detected, using Tsinghua mirror")
|
|
||||||
else:
|
|
||||||
print("[INFO] Non-Chinese locale detected, using default PyPI")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Step 1: Install unilabos in editable mode
|
|
||||||
print("[STEP 1] Installing unilabos in editable mode...")
|
|
||||||
if not install_editable(project_root, use_mirror):
|
|
||||||
print("[ERROR] Failed to install unilabos")
|
|
||||||
print()
|
|
||||||
print("Manual fallback:")
|
|
||||||
if use_mirror:
|
|
||||||
print(f" pip install -e {project_root} -i {TSINGHUA_MIRROR}")
|
|
||||||
else:
|
|
||||||
print(f" pip install -e {project_root}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Step 2: Install pip dependencies
|
|
||||||
if args.skip_deps:
|
|
||||||
print("[INFO] Skipping pip dependencies installation (--skip-deps)")
|
|
||||||
else:
|
|
||||||
print("[STEP 2] Installing pip dependencies...")
|
|
||||||
|
|
||||||
if not requirements_file.exists():
|
|
||||||
print(f"[WARN] Requirements file not found: {requirements_file}")
|
|
||||||
print("[INFO] Skipping dependencies installation")
|
|
||||||
else:
|
|
||||||
# Try uv first (faster), fallback to pip
|
|
||||||
if args.use_pip:
|
|
||||||
print("[INFO] Using pip (--use-pip flag)")
|
|
||||||
success = install_requirements_pip(requirements_file, use_mirror)
|
|
||||||
elif check_uv_available():
|
|
||||||
print("[INFO] Using uv (installed via conda-forge::uv)")
|
|
||||||
success = install_requirements_uv(requirements_file, use_mirror)
|
|
||||||
if not success:
|
|
||||||
print("[WARN] uv failed, falling back to pip...")
|
|
||||||
success = install_requirements_pip(requirements_file, use_mirror)
|
|
||||||
else:
|
|
||||||
print("[WARN] uv not available (should be installed via: mamba install conda-forge::uv)")
|
|
||||||
print("[INFO] Falling back to pip...")
|
|
||||||
success = install_requirements_pip(requirements_file, use_mirror)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
print()
|
|
||||||
print("[WARN] Failed to install some dependencies automatically.")
|
|
||||||
print("You can manually install them:")
|
|
||||||
if use_mirror:
|
|
||||||
print(f" uv pip install -r {requirements_file} -i {TSINGHUA_MIRROR}")
|
|
||||||
print(" or:")
|
|
||||||
print(f" pip install -r {requirements_file} -i {TSINGHUA_MIRROR}")
|
|
||||||
else:
|
|
||||||
print(f" uv pip install -r {requirements_file}")
|
|
||||||
print(" or:")
|
|
||||||
print(f" pip install -r {requirements_file}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print("=" * 60)
|
|
||||||
print("Installation complete!")
|
|
||||||
print("=" * 60)
|
|
||||||
print()
|
|
||||||
print("Note: Some special packages (like pylabrobot) are installed")
|
|
||||||
print("automatically at runtime by unilabos if needed.")
|
|
||||||
print()
|
|
||||||
print("Verify installation:")
|
|
||||||
print(' python -c "import unilabos; print(unilabos.__version__)"')
|
|
||||||
print()
|
|
||||||
print("If you encounter issues, you can manually install dependencies:")
|
|
||||||
if use_mirror:
|
|
||||||
print(f" uv pip install -r unilabos/utils/requirements.txt -i {TSINGHUA_MIRROR}")
|
|
||||||
else:
|
|
||||||
print(" uv pip install -r unilabos/utils/requirements.txt")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
@@ -24,15 +25,7 @@ class SimpleGraph:
|
|||||||
|
|
||||||
def add_edge(self, source, target, **attrs):
|
def add_edge(self, source, target, **attrs):
|
||||||
"""添加边"""
|
"""添加边"""
|
||||||
# edge = {"source": source, "target": target, **attrs}
|
edge = {"source": source, "target": target, **attrs}
|
||||||
edge = {
|
|
||||||
"source": source, "target": target,
|
|
||||||
"source_node_uuid": source,
|
|
||||||
"target_node_uuid": target,
|
|
||||||
"source_handle_io": "source",
|
|
||||||
"target_handle_io": "target",
|
|
||||||
**attrs
|
|
||||||
}
|
|
||||||
self.edges.append(edge)
|
self.edges.append(edge)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -49,7 +42,6 @@ class SimpleGraph:
|
|||||||
"multigraph": False,
|
"multigraph": False,
|
||||||
"graph": {},
|
"graph": {},
|
||||||
"nodes": nodes_list,
|
"nodes": nodes_list,
|
||||||
"edges": self.edges,
|
|
||||||
"links": self.edges,
|
"links": self.edges,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +58,495 @@ def extract_json_from_markdown(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_type(val: str) -> Any:
|
||||||
|
"""将字符串值转换为适当的数据类型"""
|
||||||
|
if val == "True":
|
||||||
|
return True
|
||||||
|
if val == "False":
|
||||||
|
return False
|
||||||
|
if val == "?":
|
||||||
|
return None
|
||||||
|
if val.endswith(" g"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
if val.endswith("mg"):
|
||||||
|
return float(val.split("mg")[0])
|
||||||
|
elif val.endswith("mmol"):
|
||||||
|
return float(val.split("mmol")[0]) / 1000
|
||||||
|
elif val.endswith("mol"):
|
||||||
|
return float(val.split("mol")[0])
|
||||||
|
elif val.endswith("ml"):
|
||||||
|
return float(val.split("ml")[0])
|
||||||
|
elif val.endswith("RPM"):
|
||||||
|
return float(val.split("RPM")[0])
|
||||||
|
elif val.endswith(" °C"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
elif val.endswith(" %"):
|
||||||
|
return float(val.split(" ")[0])
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def refactor_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
"""统一的数据重构函数,根据操作类型自动选择模板"""
|
||||||
|
refactored_data = []
|
||||||
|
|
||||||
|
# 定义操作映射,包含生物实验和有机化学的所有操作
|
||||||
|
OPERATION_MAPPING = {
|
||||||
|
# 生物实验操作
|
||||||
|
"transfer_liquid": "SynBioFactory-liquid_handler.prcxi-transfer_liquid",
|
||||||
|
"transfer": "SynBioFactory-liquid_handler.biomek-transfer",
|
||||||
|
"incubation": "SynBioFactory-liquid_handler.biomek-incubation",
|
||||||
|
"move_labware": "SynBioFactory-liquid_handler.biomek-move_labware",
|
||||||
|
"oscillation": "SynBioFactory-liquid_handler.biomek-oscillation",
|
||||||
|
# 有机化学操作
|
||||||
|
"HeatChillToTemp": "SynBioFactory-workstation-HeatChillProtocol",
|
||||||
|
"StopHeatChill": "SynBioFactory-workstation-HeatChillStopProtocol",
|
||||||
|
"StartHeatChill": "SynBioFactory-workstation-HeatChillStartProtocol",
|
||||||
|
"HeatChill": "SynBioFactory-workstation-HeatChillProtocol",
|
||||||
|
"Dissolve": "SynBioFactory-workstation-DissolveProtocol",
|
||||||
|
"Transfer": "SynBioFactory-workstation-TransferProtocol",
|
||||||
|
"Evaporate": "SynBioFactory-workstation-EvaporateProtocol",
|
||||||
|
"Recrystallize": "SynBioFactory-workstation-RecrystallizeProtocol",
|
||||||
|
"Filter": "SynBioFactory-workstation-FilterProtocol",
|
||||||
|
"Dry": "SynBioFactory-workstation-DryProtocol",
|
||||||
|
"Add": "SynBioFactory-workstation-AddProtocol",
|
||||||
|
}
|
||||||
|
|
||||||
|
UNSUPPORTED_OPERATIONS = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||||
|
|
||||||
|
for step in data:
|
||||||
|
operation = step.get("action")
|
||||||
|
if not operation or operation in UNSUPPORTED_OPERATIONS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 处理重复操作
|
||||||
|
if operation == "Repeat":
|
||||||
|
times = step.get("times", step.get("parameters", {}).get("times", 1))
|
||||||
|
sub_steps = step.get("steps", step.get("parameters", {}).get("steps", []))
|
||||||
|
for i in range(int(times)):
|
||||||
|
sub_data = refactor_data(sub_steps)
|
||||||
|
refactored_data.extend(sub_data)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取模板名称
|
||||||
|
template = OPERATION_MAPPING.get(operation)
|
||||||
|
if not template:
|
||||||
|
# 自动推断模板类型
|
||||||
|
if operation.lower() in ["transfer", "incubation", "move_labware", "oscillation"]:
|
||||||
|
template = f"SynBioFactory-liquid_handler.biomek-{operation}"
|
||||||
|
else:
|
||||||
|
template = f"SynBioFactory-workstation-{operation}Protocol"
|
||||||
|
|
||||||
|
# 创建步骤数据
|
||||||
|
step_data = {
|
||||||
|
"template": template,
|
||||||
|
"description": step.get("description", step.get("purpose", f"{operation} operation")),
|
||||||
|
"lab_node_type": "Device",
|
||||||
|
"parameters": step.get("parameters", step.get("action_args", {})),
|
||||||
|
}
|
||||||
|
refactored_data.append(step_data)
|
||||||
|
|
||||||
|
return refactored_data
|
||||||
|
|
||||||
|
|
||||||
|
def build_protocol_graph(
|
||||||
|
labware_info: List[Dict[str, Any]], protocol_steps: List[Dict[str, Any]], workstation_name: str
|
||||||
|
) -> SimpleGraph:
|
||||||
|
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑"""
|
||||||
|
G = SimpleGraph()
|
||||||
|
resource_last_writer = {}
|
||||||
|
LAB_NAME = "SynBioFactory"
|
||||||
|
|
||||||
|
protocol_steps = refactor_data(protocol_steps)
|
||||||
|
|
||||||
|
# 检查协议步骤中的模板来判断协议类型
|
||||||
|
has_biomek_template = any(
|
||||||
|
("biomek" in step.get("template", "")) or ("prcxi" in step.get("template", ""))
|
||||||
|
for step in protocol_steps
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_biomek_template:
|
||||||
|
# 生物实验协议图构建
|
||||||
|
for labware_id, labware in labware_info.items():
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
labware_attrs = labware.copy()
|
||||||
|
labware_id = labware_attrs.pop("id", labware_attrs.get("name", f"labware_{uuid.uuid4()}"))
|
||||||
|
labware_attrs["description"] = labware_id
|
||||||
|
labware_attrs["lab_node_type"] = (
|
||||||
|
"Reagent" if "Plate" in str(labware_id) else "Labware" if "Rack" in str(labware_id) else "Sample"
|
||||||
|
)
|
||||||
|
labware_attrs["device_id"] = workstation_name
|
||||||
|
|
||||||
|
G.add_node(node_id, template=f"{LAB_NAME}-host_node-create_resource", **labware_attrs)
|
||||||
|
resource_last_writer[labware_id] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
# 处理协议步骤
|
||||||
|
prev_node = None
|
||||||
|
for i, step in enumerate(protocol_steps):
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
G.add_node(node_id, **step)
|
||||||
|
|
||||||
|
# 添加控制流边
|
||||||
|
if prev_node is not None:
|
||||||
|
G.add_edge(prev_node, node_id, source_port="ready", target_port="ready")
|
||||||
|
prev_node = node_id
|
||||||
|
|
||||||
|
# 处理物料流
|
||||||
|
params = step.get("parameters", {})
|
||||||
|
if "sources" in params and params["sources"] in resource_last_writer:
|
||||||
|
source_node, source_port = resource_last_writer[params["sources"]].split(":")
|
||||||
|
G.add_edge(source_node, node_id, source_port=source_port, target_port="labware")
|
||||||
|
|
||||||
|
if "targets" in params:
|
||||||
|
resource_last_writer[params["targets"]] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
# 添加协议结束节点
|
||||||
|
end_id = str(uuid.uuid4())
|
||||||
|
G.add_node(end_id, template=f"{LAB_NAME}-liquid_handler.biomek-run_protocol")
|
||||||
|
if prev_node is not None:
|
||||||
|
G.add_edge(prev_node, end_id, source_port="ready", target_port="ready")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 有机化学协议图构建
|
||||||
|
WORKSTATION_ID = workstation_name
|
||||||
|
|
||||||
|
# 为所有labware创建资源节点
|
||||||
|
for item_id, item in labware_info.items():
|
||||||
|
# item_id = item.get("id") or item.get("name", f"item_{uuid.uuid4()}")
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# 判断节点类型
|
||||||
|
if item.get("type") == "hardware" or "reactor" in str(item_id).lower():
|
||||||
|
if "reactor" not in str(item_id).lower():
|
||||||
|
continue
|
||||||
|
lab_node_type = "Sample"
|
||||||
|
description = f"Prepare Reactor: {item_id}"
|
||||||
|
liquid_type = []
|
||||||
|
liquid_volume = []
|
||||||
|
else:
|
||||||
|
lab_node_type = "Reagent"
|
||||||
|
description = f"Add Reagent to Flask: {item_id}"
|
||||||
|
liquid_type = [item_id]
|
||||||
|
liquid_volume = [1e5]
|
||||||
|
|
||||||
|
G.add_node(
|
||||||
|
node_id,
|
||||||
|
template=f"{LAB_NAME}-host_node-create_resource",
|
||||||
|
description=description,
|
||||||
|
lab_node_type=lab_node_type,
|
||||||
|
res_id=item_id,
|
||||||
|
device_id=WORKSTATION_ID,
|
||||||
|
class_name="container",
|
||||||
|
parent=WORKSTATION_ID,
|
||||||
|
bind_locations={"x": 0.0, "y": 0.0, "z": 0.0},
|
||||||
|
liquid_input_slot=[-1],
|
||||||
|
liquid_type=liquid_type,
|
||||||
|
liquid_volume=liquid_volume,
|
||||||
|
slot_on_deck="",
|
||||||
|
role=item.get("role", ""),
|
||||||
|
)
|
||||||
|
resource_last_writer[item_id] = f"{node_id}:labware"
|
||||||
|
|
||||||
|
last_control_node_id = None
|
||||||
|
|
||||||
|
# 处理协议步骤
|
||||||
|
for step in protocol_steps:
|
||||||
|
node_id = str(uuid.uuid4())
|
||||||
|
G.add_node(node_id, **step)
|
||||||
|
|
||||||
|
# 控制流
|
||||||
|
if last_control_node_id is not None:
|
||||||
|
G.add_edge(last_control_node_id, node_id, source_port="ready", target_port="ready")
|
||||||
|
last_control_node_id = node_id
|
||||||
|
|
||||||
|
# 物料流
|
||||||
|
params = step.get("parameters", {})
|
||||||
|
input_resources = {
|
||||||
|
"Vessel": params.get("vessel"),
|
||||||
|
"ToVessel": params.get("to_vessel"),
|
||||||
|
"FromVessel": params.get("from_vessel"),
|
||||||
|
"reagent": params.get("reagent"),
|
||||||
|
"solvent": params.get("solvent"),
|
||||||
|
"compound": params.get("compound"),
|
||||||
|
"sources": params.get("sources"),
|
||||||
|
"targets": params.get("targets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for target_port, resource_name in input_resources.items():
|
||||||
|
if resource_name and resource_name in resource_last_writer:
|
||||||
|
source_node, source_port = resource_last_writer[resource_name].split(":")
|
||||||
|
G.add_edge(source_node, node_id, source_port=source_port, target_port=target_port)
|
||||||
|
|
||||||
|
output_resources = {
|
||||||
|
"VesselOut": params.get("vessel"),
|
||||||
|
"FromVesselOut": params.get("from_vessel"),
|
||||||
|
"ToVesselOut": params.get("to_vessel"),
|
||||||
|
"FiltrateOut": params.get("filtrate_vessel"),
|
||||||
|
"reagent": params.get("reagent"),
|
||||||
|
"solvent": params.get("solvent"),
|
||||||
|
"compound": params.get("compound"),
|
||||||
|
"sources_out": params.get("sources"),
|
||||||
|
"targets_out": params.get("targets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for source_port, resource_name in output_resources.items():
|
||||||
|
if resource_name:
|
||||||
|
resource_last_writer[resource_name] = f"{node_id}:{source_port}"
|
||||||
|
|
||||||
|
return G
|
||||||
|
|
||||||
|
|
||||||
|
def draw_protocol_graph(protocol_graph: SimpleGraph, output_path: str):
|
||||||
|
"""
|
||||||
|
(辅助功能) 使用 networkx 和 matplotlib 绘制协议工作流图,用于可视化。
|
||||||
|
"""
|
||||||
|
if not protocol_graph:
|
||||||
|
print("Cannot draw graph: Graph object is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
G = nx.DiGraph()
|
||||||
|
|
||||||
|
for node_id, attrs in protocol_graph.nodes.items():
|
||||||
|
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||||
|
G.add_node(node_id, label=label, **attrs)
|
||||||
|
|
||||||
|
for edge in protocol_graph.edges:
|
||||||
|
G.add_edge(edge["source"], edge["target"])
|
||||||
|
|
||||||
|
plt.figure(figsize=(20, 15))
|
||||||
|
try:
|
||||||
|
pos = nx.nx_agraph.graphviz_layout(G, prog="dot")
|
||||||
|
except Exception:
|
||||||
|
pos = nx.shell_layout(G) # Fallback layout
|
||||||
|
|
||||||
|
node_labels = {node: data["label"] for node, data in G.nodes(data=True)}
|
||||||
|
nx.draw(
|
||||||
|
G,
|
||||||
|
pos,
|
||||||
|
with_labels=False,
|
||||||
|
node_size=2500,
|
||||||
|
node_color="skyblue",
|
||||||
|
node_shape="o",
|
||||||
|
edge_color="gray",
|
||||||
|
width=1.5,
|
||||||
|
arrowsize=15,
|
||||||
|
)
|
||||||
|
nx.draw_networkx_labels(G, pos, labels=node_labels, font_size=8, font_weight="bold")
|
||||||
|
|
||||||
|
plt.title("Chemical Protocol Workflow Graph", size=15)
|
||||||
|
plt.savefig(output_path, dpi=300, bbox_inches="tight")
|
||||||
|
plt.close()
|
||||||
|
print(f" - Visualization saved to '{output_path}'")
|
||||||
|
|
||||||
|
|
||||||
|
from networkx.drawing.nx_agraph import to_agraph
|
||||||
|
import re
|
||||||
|
|
||||||
|
COMPASS = {"n","e","s","w","ne","nw","se","sw","c"}
|
||||||
|
|
||||||
|
def _is_compass(port: str) -> bool:
|
||||||
|
return isinstance(port, str) and port.lower() in COMPASS
|
||||||
|
|
||||||
|
def draw_protocol_graph_with_ports(protocol_graph, output_path: str, rankdir: str = "LR"):
|
||||||
|
"""
|
||||||
|
使用 Graphviz 端口语法绘制协议工作流图。
|
||||||
|
- 若边上的 source_port/target_port 是 compass(n/e/s/w/...),直接用 compass。
|
||||||
|
- 否则自动为节点创建 record 形状并定义命名端口 <portname>。
|
||||||
|
最终由 PyGraphviz 渲染并输出到 output_path(后缀决定格式,如 .png/.svg/.pdf)。
|
||||||
|
"""
|
||||||
|
if not protocol_graph:
|
||||||
|
print("Cannot draw graph: Graph object is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1) 先用 networkx 搭建有向图,保留端口属性
|
||||||
|
G = nx.DiGraph()
|
||||||
|
for node_id, attrs in protocol_graph.nodes.items():
|
||||||
|
label = attrs.get("description", attrs.get("template", node_id[:8]))
|
||||||
|
# 保留一个干净的“中心标签”,用于放在 record 的中间槽
|
||||||
|
G.add_node(node_id, _core_label=str(label), **{k:v for k,v in attrs.items() if k not in ("label",)})
|
||||||
|
|
||||||
|
edges_data = []
|
||||||
|
in_ports_by_node = {} # 收集命名输入端口
|
||||||
|
out_ports_by_node = {} # 收集命名输出端口
|
||||||
|
|
||||||
|
for edge in protocol_graph.edges:
|
||||||
|
u = edge["source"]
|
||||||
|
v = edge["target"]
|
||||||
|
sp = edge.get("source_port")
|
||||||
|
tp = edge.get("target_port")
|
||||||
|
|
||||||
|
# 记录到图里(保留原始端口信息)
|
||||||
|
G.add_edge(u, v, source_port=sp, target_port=tp)
|
||||||
|
edges_data.append((u, v, sp, tp))
|
||||||
|
|
||||||
|
# 如果不是 compass,就按“命名端口”先归类,等会儿给节点造 record
|
||||||
|
if sp and not _is_compass(sp):
|
||||||
|
out_ports_by_node.setdefault(u, set()).add(str(sp))
|
||||||
|
if tp and not _is_compass(tp):
|
||||||
|
in_ports_by_node.setdefault(v, set()).add(str(tp))
|
||||||
|
|
||||||
|
# 2) 转为 AGraph,使用 Graphviz 渲染
|
||||||
|
A = to_agraph(G)
|
||||||
|
A.graph_attr.update(rankdir=rankdir, splines="true", concentrate="false", fontsize="10")
|
||||||
|
A.node_attr.update(shape="box", style="rounded,filled", fillcolor="lightyellow", color="#999999", fontname="Helvetica")
|
||||||
|
A.edge_attr.update(arrowsize="0.8", color="#666666")
|
||||||
|
|
||||||
|
# 3) 为需要命名端口的节点设置 record 形状与 label
|
||||||
|
# 左列 = 输入端口;中间 = 核心标签;右列 = 输出端口
|
||||||
|
for n in A.nodes():
|
||||||
|
node = A.get_node(n)
|
||||||
|
core = G.nodes[n].get("_core_label", n)
|
||||||
|
|
||||||
|
in_ports = sorted(in_ports_by_node.get(n, []))
|
||||||
|
out_ports = sorted(out_ports_by_node.get(n, []))
|
||||||
|
|
||||||
|
# 如果该节点涉及命名端口,则用 record;否则保留原 box
|
||||||
|
if in_ports or out_ports:
|
||||||
|
def port_fields(ports):
|
||||||
|
if not ports:
|
||||||
|
return " " # 必须留一个空槽占位
|
||||||
|
# 每个端口一个小格子,<p> name
|
||||||
|
return "|".join(f"<{re.sub(r'[^A-Za-z0-9_:.|-]', '_', p)}> {p}" for p in ports)
|
||||||
|
|
||||||
|
left = port_fields(in_ports)
|
||||||
|
right = port_fields(out_ports)
|
||||||
|
|
||||||
|
# 三栏:左(入) | 中(节点名) | 右(出)
|
||||||
|
record_label = f"{{ {left} | {core} | {right} }}"
|
||||||
|
node.attr.update(shape="record", label=record_label)
|
||||||
|
else:
|
||||||
|
# 没有命名端口:普通盒子,显示核心标签
|
||||||
|
node.attr.update(label=str(core))
|
||||||
|
|
||||||
|
# 4) 给边设置 headport / tailport
|
||||||
|
# - 若端口为 compass:直接用 compass(e.g., headport="e")
|
||||||
|
# - 若端口为命名端口:使用在 record 中定义的 <port> 名(同名即可)
|
||||||
|
for (u, v, sp, tp) in edges_data:
|
||||||
|
e = A.get_edge(u, v)
|
||||||
|
|
||||||
|
# Graphviz 属性:tail 是源,head 是目标
|
||||||
|
if sp:
|
||||||
|
if _is_compass(sp):
|
||||||
|
e.attr["tailport"] = sp.lower()
|
||||||
|
else:
|
||||||
|
# 与 record label 中 <port> 名一致;特殊字符已在 label 中做了清洗
|
||||||
|
e.attr["tailport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(sp))
|
||||||
|
|
||||||
|
if tp:
|
||||||
|
if _is_compass(tp):
|
||||||
|
e.attr["headport"] = tp.lower()
|
||||||
|
else:
|
||||||
|
e.attr["headport"] = re.sub(r'[^A-Za-z0-9_:.|-]', '_', str(tp))
|
||||||
|
|
||||||
|
# 可选:若想让边更贴边缘,可设置 constraint/spline 等
|
||||||
|
# e.attr["arrowhead"] = "vee"
|
||||||
|
|
||||||
|
# 5) 输出
|
||||||
|
A.draw(output_path, prog="dot")
|
||||||
|
print(f" - Port-aware workflow rendered to '{output_path}'")
|
||||||
|
|
||||||
|
|
||||||
|
def flatten_xdl_procedure(procedure_elem: ET.Element) -> List[ET.Element]:
|
||||||
|
"""展平嵌套的XDL程序结构"""
|
||||||
|
flattened_operations = []
|
||||||
|
TEMP_UNSUPPORTED_PROTOCOL = ["Purge", "Wait", "Stir", "ResetHandling"]
|
||||||
|
|
||||||
|
def extract_operations(element: ET.Element):
|
||||||
|
if element.tag not in ["Prep", "Reaction", "Workup", "Purification", "Procedure"]:
|
||||||
|
if element.tag not in TEMP_UNSUPPORTED_PROTOCOL:
|
||||||
|
flattened_operations.append(element)
|
||||||
|
|
||||||
|
for child in element:
|
||||||
|
extract_operations(child)
|
||||||
|
|
||||||
|
for child in procedure_elem:
|
||||||
|
extract_operations(child)
|
||||||
|
|
||||||
|
return flattened_operations
|
||||||
|
|
||||||
|
|
||||||
|
def parse_xdl_content(xdl_content: str) -> tuple:
|
||||||
|
"""解析XDL内容"""
|
||||||
|
try:
|
||||||
|
xdl_content_cleaned = "".join(c for c in xdl_content if c.isprintable())
|
||||||
|
root = ET.fromstring(xdl_content_cleaned)
|
||||||
|
|
||||||
|
synthesis_elem = root.find("Synthesis")
|
||||||
|
if synthesis_elem is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
# 解析硬件组件
|
||||||
|
hardware_elem = synthesis_elem.find("Hardware")
|
||||||
|
hardware = []
|
||||||
|
if hardware_elem is not None:
|
||||||
|
hardware = [{"id": c.get("id"), "type": c.get("type")} for c in hardware_elem.findall("Component")]
|
||||||
|
|
||||||
|
# 解析试剂
|
||||||
|
reagents_elem = synthesis_elem.find("Reagents")
|
||||||
|
reagents = []
|
||||||
|
if reagents_elem is not None:
|
||||||
|
reagents = [{"name": r.get("name"), "role": r.get("role", "")} for r in reagents_elem.findall("Reagent")]
|
||||||
|
|
||||||
|
# 解析程序
|
||||||
|
procedure_elem = synthesis_elem.find("Procedure")
|
||||||
|
if procedure_elem is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
flattened_operations = flatten_xdl_procedure(procedure_elem)
|
||||||
|
return hardware, reagents, flattened_operations
|
||||||
|
|
||||||
|
except ET.ParseError as e:
|
||||||
|
raise ValueError(f"Invalid XDL format: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def convert_xdl_to_dict(xdl_content: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
将XDL XML格式转换为标准的字典格式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xdl_content: XDL XML内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
转换结果,包含步骤和器材信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hardware, reagents, flattened_operations = parse_xdl_content(xdl_content)
|
||||||
|
if hardware is None:
|
||||||
|
return {"error": "Failed to parse XDL content", "success": False}
|
||||||
|
|
||||||
|
# 将XDL元素转换为字典格式
|
||||||
|
steps_data = []
|
||||||
|
for elem in flattened_operations:
|
||||||
|
# 转换参数类型
|
||||||
|
parameters = {}
|
||||||
|
for key, val in elem.attrib.items():
|
||||||
|
converted_val = convert_to_type(val)
|
||||||
|
if converted_val is not None:
|
||||||
|
parameters[key] = converted_val
|
||||||
|
|
||||||
|
step_dict = {
|
||||||
|
"operation": elem.tag,
|
||||||
|
"parameters": parameters,
|
||||||
|
"description": elem.get("purpose", f"Operation: {elem.tag}"),
|
||||||
|
}
|
||||||
|
steps_data.append(step_dict)
|
||||||
|
|
||||||
|
# 合并硬件和试剂为统一的labware_info格式
|
||||||
|
labware_data = []
|
||||||
|
labware_data.extend({"id": hw["id"], "type": "hardware", **hw} for hw in hardware)
|
||||||
|
labware_data.extend({"name": reagent["name"], "type": "reagent", **reagent} for reagent in reagents)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"steps": steps_data,
|
||||||
|
"labware": labware_data,
|
||||||
|
"message": f"Successfully converted XDL to dict format. Found {len(steps_data)} steps and {len(labware_data)} labware items.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"XDL conversion failed: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return {"error": error_msg, "success": False}
|
||||||
|
|
||||||
|
|
||||||
def create_workflow(
|
def create_workflow(
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name=package_name,
|
name=package_name,
|
||||||
version='0.10.19',
|
version='0.10.12',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=['setuptools'],
|
install_requires=['setuptools'],
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
"""
|
|
||||||
测试包根目录。
|
|
||||||
|
|
||||||
让 `tests.*` 模块可以被正常 import(例如给 `unilabos` 下的测试入口使用)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
"""
|
|
||||||
液体处理设备相关测试。
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).resolve().parents[2]
|
|
||||||
if str(ROOT_DIR) not in sys.path:
|
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from unilabos.workflow.convert_from_json import (
|
|
||||||
convert_from_json,
|
|
||||||
normalize_steps as _normalize_steps,
|
|
||||||
normalize_labware as _normalize_labware,
|
|
||||||
)
|
|
||||||
from unilabos.workflow.common import draw_protocol_graph_with_ports
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"protocol_name",
|
|
||||||
[
|
|
||||||
"example_bio",
|
|
||||||
# "bioyond_materials_liquidhandling_1",
|
|
||||||
"example_prcxi",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_build_protocol_graph(protocol_name):
|
|
||||||
data_path = Path(__file__).with_name(f"{protocol_name}.json")
|
|
||||||
|
|
||||||
graph = convert_from_json(data_path, workstation_name="PRCXi")
|
|
||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
|
||||||
output_path = data_path.with_name(f"{protocol_name}_graph_{timestamp}.png")
|
|
||||||
draw_protocol_graph_with_ports(graph, str(output_path))
|
|
||||||
print(graph)
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
{
|
|
||||||
"workflow": [
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines",
|
|
||||||
"targets": "Liquid_1",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines",
|
|
||||||
"targets": "Liquid_2",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines",
|
|
||||||
"targets": "Liquid_3",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines_2",
|
|
||||||
"targets": "Liquid_4",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines_2",
|
|
||||||
"targets": "Liquid_5",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines_2",
|
|
||||||
"targets": "Liquid_6",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines_3",
|
|
||||||
"targets": "dest_set",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines_3",
|
|
||||||
"targets": "dest_set_2",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"action": "transfer_liquid",
|
|
||||||
"action_args": {
|
|
||||||
"sources": "cell_lines_3",
|
|
||||||
"targets": "dest_set_3",
|
|
||||||
"asp_vol": 100.0,
|
|
||||||
"dis_vol": 74.75,
|
|
||||||
"asp_flow_rate": 94.0,
|
|
||||||
"dis_flow_rate": 95.5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"reagent": {
|
|
||||||
"Liquid_1": {
|
|
||||||
"slot": 1,
|
|
||||||
"well": [
|
|
||||||
"A4",
|
|
||||||
"A7",
|
|
||||||
"A10"
|
|
||||||
],
|
|
||||||
"labware": "rep 1"
|
|
||||||
},
|
|
||||||
"Liquid_4": {
|
|
||||||
"slot": 1,
|
|
||||||
"well": [
|
|
||||||
"A4",
|
|
||||||
"A7",
|
|
||||||
"A10"
|
|
||||||
],
|
|
||||||
"labware": "rep 1"
|
|
||||||
},
|
|
||||||
"dest_set": {
|
|
||||||
"slot": 1,
|
|
||||||
"well": [
|
|
||||||
"A4",
|
|
||||||
"A7",
|
|
||||||
"A10"
|
|
||||||
],
|
|
||||||
"labware": "rep 1"
|
|
||||||
},
|
|
||||||
"Liquid_2": {
|
|
||||||
"slot": 2,
|
|
||||||
"well": [
|
|
||||||
"A3",
|
|
||||||
"A5",
|
|
||||||
"A8"
|
|
||||||
],
|
|
||||||
"labware": "rep 2"
|
|
||||||
},
|
|
||||||
"Liquid_5": {
|
|
||||||
"slot": 2,
|
|
||||||
"well": [
|
|
||||||
"A3",
|
|
||||||
"A5",
|
|
||||||
"A8"
|
|
||||||
],
|
|
||||||
"labware": "rep 2"
|
|
||||||
},
|
|
||||||
"dest_set_2": {
|
|
||||||
"slot": 2,
|
|
||||||
"well": [
|
|
||||||
"A3",
|
|
||||||
"A5",
|
|
||||||
"A8"
|
|
||||||
],
|
|
||||||
"labware": "rep 2"
|
|
||||||
},
|
|
||||||
"Liquid_3": {
|
|
||||||
"slot": 3,
|
|
||||||
"well": [
|
|
||||||
"A4",
|
|
||||||
"A6",
|
|
||||||
"A10"
|
|
||||||
],
|
|
||||||
"labware": "rep 3"
|
|
||||||
},
|
|
||||||
"Liquid_6": {
|
|
||||||
"slot": 3,
|
|
||||||
"well": [
|
|
||||||
"A4",
|
|
||||||
"A6",
|
|
||||||
"A10"
|
|
||||||
],
|
|
||||||
"labware": "rep 3"
|
|
||||||
},
|
|
||||||
"dest_set_3": {
|
|
||||||
"slot": 3,
|
|
||||||
"well": [
|
|
||||||
"A4",
|
|
||||||
"A6",
|
|
||||||
"A10"
|
|
||||||
],
|
|
||||||
"labware": "rep 3"
|
|
||||||
},
|
|
||||||
"cell_lines": {
|
|
||||||
"slot": 4,
|
|
||||||
"well": [
|
|
||||||
"A1",
|
|
||||||
"A3",
|
|
||||||
"A5"
|
|
||||||
],
|
|
||||||
"labware": "DRUG + YOYO-MEDIA"
|
|
||||||
},
|
|
||||||
"cell_lines_2": {
|
|
||||||
"slot": 4,
|
|
||||||
"well": [
|
|
||||||
"A1",
|
|
||||||
"A3",
|
|
||||||
"A5"
|
|
||||||
],
|
|
||||||
"labware": "DRUG + YOYO-MEDIA"
|
|
||||||
},
|
|
||||||
"cell_lines_3": {
|
|
||||||
"slot": 4,
|
|
||||||
"well": [
|
|
||||||
"A1",
|
|
||||||
"A3",
|
|
||||||
"A5"
|
|
||||||
],
|
|
||||||
"labware": "DRUG + YOYO-MEDIA"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.10.19"
|
__version__ = "0.10.12"
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Entry point for `python -m unilabos`."""
|
|
||||||
|
|
||||||
from unilabos.app.main import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet
|
||||||
from unilabos.utils import logger
|
from unilabos.utils import logger
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import platform
|
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, List
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -18,93 +17,9 @@ unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
|
|||||||
if unilabos_dir not in sys.path:
|
if unilabos_dir not in sys.path:
|
||||||
sys.path.append(unilabos_dir)
|
sys.path.append(unilabos_dir)
|
||||||
|
|
||||||
from unilabos.app.utils import cleanup_for_restart
|
|
||||||
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
|
||||||
|
|
||||||
# Global restart flags (used by ws_client and web/server)
|
|
||||||
_restart_requested: bool = False
|
|
||||||
_restart_reason: str = ""
|
|
||||||
|
|
||||||
RESTART_EXIT_CODE = 42
|
|
||||||
|
|
||||||
|
|
||||||
def _build_child_argv():
|
|
||||||
"""Build sys.argv for child process, stripping supervisor-only arguments."""
|
|
||||||
result = []
|
|
||||||
skip_next = False
|
|
||||||
for arg in sys.argv:
|
|
||||||
if skip_next:
|
|
||||||
skip_next = False
|
|
||||||
continue
|
|
||||||
if arg in ("--restart_mode", "--restart-mode"):
|
|
||||||
continue
|
|
||||||
if arg in ("--auto_restart_count", "--auto-restart-count"):
|
|
||||||
skip_next = True
|
|
||||||
continue
|
|
||||||
if arg.startswith("--auto_restart_count=") or arg.startswith("--auto-restart-count="):
|
|
||||||
continue
|
|
||||||
result.append(arg)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _run_as_supervisor(max_restarts: int):
|
|
||||||
"""
|
|
||||||
Supervisor process that spawns and monitors child processes.
|
|
||||||
|
|
||||||
Similar to Uvicorn's --reload: the supervisor itself does no heavy work,
|
|
||||||
it only launches the real process as a child and restarts it when the child
|
|
||||||
exits with RESTART_EXIT_CODE.
|
|
||||||
"""
|
|
||||||
child_argv = [sys.executable] + _build_child_argv()
|
|
||||||
restart_count = 0
|
|
||||||
|
|
||||||
print_status(
|
|
||||||
f"[Supervisor] Restart mode enabled (max restarts: {max_restarts}), "
|
|
||||||
f"child command: {' '.join(child_argv)}",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
print_status(
|
|
||||||
f"[Supervisor] Launching process (restart {restart_count}/{max_restarts})...",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
process = subprocess.Popen(child_argv)
|
|
||||||
exit_code = process.wait()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print_status("[Supervisor] Interrupted, terminating child process...", "info")
|
|
||||||
process.terminate()
|
|
||||||
try:
|
|
||||||
process.wait(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
process.kill()
|
|
||||||
process.wait()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if exit_code == RESTART_EXIT_CODE:
|
|
||||||
restart_count += 1
|
|
||||||
if restart_count > max_restarts:
|
|
||||||
print_status(
|
|
||||||
f"[Supervisor] Maximum restart count ({max_restarts}) reached, exiting",
|
|
||||||
"warning",
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
print_status(
|
|
||||||
f"[Supervisor] Child requested restart ({restart_count}/{max_restarts}), restarting in 2s...",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
time.sleep(2)
|
|
||||||
else:
|
|
||||||
if exit_code != 0:
|
|
||||||
print_status(f"[Supervisor] Child exited with code {exit_code}", "warning")
|
|
||||||
else:
|
|
||||||
print_status("[Supervisor] Child exited normally", "info")
|
|
||||||
sys.exit(exit_code)
|
|
||||||
|
|
||||||
|
|
||||||
def load_config_from_file(config_path):
|
def load_config_from_file(config_path):
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
|
config_path = os.environ.get("UNILABOS_BASICCONFIG_CONFIG_PATH", None)
|
||||||
@@ -134,8 +49,6 @@ def convert_argv_dashes_to_underscores(args: argparse.ArgumentParser):
|
|||||||
def parse_args():
|
def parse_args():
|
||||||
"""解析命令行参数"""
|
"""解析命令行参数"""
|
||||||
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
parser = argparse.ArgumentParser(description="Start Uni-Lab Edge server.")
|
||||||
subparsers = parser.add_subparsers(title="Valid subcommands", dest="command")
|
|
||||||
|
|
||||||
parser.add_argument("-g", "--graph", help="Physical setup graph file path.")
|
parser.add_argument("-g", "--graph", help="Physical setup graph file path.")
|
||||||
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.")
|
parser.add_argument("-c", "--controllers", default=None, help="Controllers config file path.")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -145,13 +58,6 @@ def parse_args():
|
|||||||
action="append",
|
action="append",
|
||||||
help="Path to the registry directory",
|
help="Path to the registry directory",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--devices",
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
action="append",
|
|
||||||
help="Path to Python code directory for AST-based device/resource scanning",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--working_dir",
|
"--working_dir",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -241,91 +147,11 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Skip environment dependency check on startup",
|
help="Skip environment dependency check on startup",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--check_mode",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--complete_registry",
|
"--complete_registry",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help="Complete and rewrite YAML registry files using AST analysis results",
|
help="Complete registry information",
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--no_update_feedback",
|
|
||||||
action="store_true",
|
|
||||||
help="Disable sending update feedback to server",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--test_mode",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--external_devices_only",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Only load external device packages (--devices), skip built-in unilabos/devices/ scanning and YAML device registry",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--extra_resource",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Load extra lab_ prefixed labware resources (529 auto-generated definitions from lab_resources.py)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--restart_mode",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Enable supervisor mode: automatically restart the process when triggered via WebSocket",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--auto_restart_count",
|
|
||||||
type=int,
|
|
||||||
default=500,
|
|
||||||
help="Maximum number of automatic restarts in restart mode (default: 500)",
|
|
||||||
)
|
|
||||||
# workflow upload subcommand
|
|
||||||
workflow_parser = subparsers.add_parser(
|
|
||||||
"workflow_upload",
|
|
||||||
aliases=["wf"],
|
|
||||||
help="Upload workflow from xdl/json/python files",
|
|
||||||
)
|
|
||||||
workflow_parser.add_argument(
|
|
||||||
"-f",
|
|
||||||
"--workflow_file",
|
|
||||||
type=str,
|
|
||||||
required=True,
|
|
||||||
help="Path to the workflow file (JSON format)",
|
|
||||||
)
|
|
||||||
workflow_parser.add_argument(
|
|
||||||
"-n",
|
|
||||||
"--workflow_name",
|
|
||||||
type=str,
|
|
||||||
default=None,
|
|
||||||
help="Workflow name, if not provided will use the name from file or filename",
|
|
||||||
)
|
|
||||||
workflow_parser.add_argument(
|
|
||||||
"--tags",
|
|
||||||
type=str,
|
|
||||||
nargs="*",
|
|
||||||
default=[],
|
|
||||||
help="Tags for the workflow (space-separated)",
|
|
||||||
)
|
|
||||||
workflow_parser.add_argument(
|
|
||||||
"--published",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Whether to publish the workflow (default: False)",
|
|
||||||
)
|
|
||||||
workflow_parser.add_argument(
|
|
||||||
"--description",
|
|
||||||
type=str,
|
|
||||||
default="",
|
|
||||||
help="Workflow description, used when publishing the workflow",
|
|
||||||
)
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
@@ -333,97 +159,58 @@ def parse_args():
|
|||||||
def main():
|
def main():
|
||||||
"""主函数"""
|
"""主函数"""
|
||||||
# 解析命令行参数
|
# 解析命令行参数
|
||||||
parser = parse_args()
|
args = parse_args()
|
||||||
convert_argv_dashes_to_underscores(parser)
|
convert_argv_dashes_to_underscores(args)
|
||||||
args = parser.parse_args()
|
args_dict = vars(args.parse_args())
|
||||||
args_dict = vars(args)
|
|
||||||
|
|
||||||
# Supervisor mode: spawn child processes and monitor for restart
|
|
||||||
if args_dict.get("restart_mode", False):
|
|
||||||
_run_as_supervisor(args_dict.get("auto_restart_count", 5))
|
|
||||||
return
|
|
||||||
|
|
||||||
# 环境检查 - 检查并自动安装必需的包 (可选)
|
# 环境检查 - 检查并自动安装必需的包 (可选)
|
||||||
skip_env_check = args_dict.get("skip_env_check", False)
|
if not args_dict.get("skip_env_check", False):
|
||||||
check_mode = args_dict.get("check_mode", False)
|
from unilabos.utils.environment_check import check_environment
|
||||||
|
|
||||||
if not skip_env_check:
|
|
||||||
from unilabos.utils.environment_check import check_environment, check_device_package_requirements
|
|
||||||
|
|
||||||
|
print_status("正在进行环境依赖检查...", "info")
|
||||||
if not check_environment(auto_install=True):
|
if not check_environment(auto_install=True):
|
||||||
print_status("环境检查失败,程序退出", "error")
|
print_status("环境检查失败,程序退出", "error")
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
|
||||||
# 第一次设备包依赖检查:build_registry 之前,确保 import map 可用
|
|
||||||
devices_dirs_for_req = args_dict.get("devices", None)
|
|
||||||
if devices_dirs_for_req:
|
|
||||||
if not check_device_package_requirements(devices_dirs_for_req):
|
|
||||||
print_status("设备包依赖检查失败,程序退出", "error")
|
|
||||||
os._exit(1)
|
|
||||||
else:
|
else:
|
||||||
print_status("跳过环境依赖检查", "warning")
|
print_status("跳过环境依赖检查", "warning")
|
||||||
|
|
||||||
# 加载配置文件,优先加载config,然后从env读取
|
# 加载配置文件,优先加载config,然后从env读取
|
||||||
config_path = args_dict.get("config")
|
config_path = args_dict.get("config")
|
||||||
|
if os.getcwd().endswith("unilabos_data"):
|
||||||
# === 解析 working_dir ===
|
|
||||||
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
|
|
||||||
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
|
|
||||||
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
|
|
||||||
raw_working_dir = args_dict.get("working_dir")
|
|
||||||
if raw_working_dir:
|
|
||||||
working_dir = os.path.abspath(raw_working_dir)
|
|
||||||
elif config_path and os.path.exists(config_path):
|
|
||||||
working_dir = os.path.dirname(os.path.abspath(config_path))
|
|
||||||
else:
|
|
||||||
working_dir = os.path.abspath(os.getcwd())
|
working_dir = os.path.abspath(os.getcwd())
|
||||||
|
else:
|
||||||
# unilabos_data 子目录自动检测
|
|
||||||
if os.path.basename(working_dir) != "unilabos_data":
|
|
||||||
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
|
|
||||||
if os.path.isdir(unilabos_data_sub):
|
|
||||||
working_dir = unilabos_data_sub
|
|
||||||
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
|
|
||||||
# 未显式指定路径,默认使用 cwd/unilabos_data
|
|
||||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||||
|
|
||||||
# === 解析 config_path ===
|
if args_dict.get("working_dir"):
|
||||||
|
working_dir = args_dict.get("working_dir", "")
|
||||||
if config_path and not os.path.exists(config_path):
|
if config_path and not os.path.exists(config_path):
|
||||||
# config_path 传入但不存在,尝试在 working_dir 中查找
|
config_path = os.path.join(working_dir, "local_config.py")
|
||||||
candidate = os.path.join(working_dir, "local_config.py")
|
if not os.path.exists(config_path):
|
||||||
if os.path.exists(candidate):
|
|
||||||
config_path = candidate
|
|
||||||
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
|
|
||||||
else:
|
|
||||||
print_status(
|
print_status(
|
||||||
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py,"
|
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
|
||||||
f"请通过 --config 传入 local_config.py 文件路径",
|
|
||||||
"error",
|
"error",
|
||||||
)
|
)
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
elif not config_path:
|
elif config_path and os.path.exists(config_path):
|
||||||
# 规则3: 未传入 config_path,尝试 working_dir/local_config.py
|
working_dir = os.path.dirname(config_path)
|
||||||
candidate = os.path.join(working_dir, "local_config.py")
|
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
|
||||||
if os.path.exists(candidate):
|
config_path = os.path.join(working_dir, "local_config.py")
|
||||||
config_path = candidate
|
elif not config_path and (
|
||||||
print_status(f"发现本地配置文件: {config_path}", "info")
|
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
|
||||||
else:
|
):
|
||||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||||
if check_mode or input() != "n":
|
if input() != "n":
|
||||||
os.makedirs(working_dir, exist_ok=True)
|
os.makedirs(working_dir, exist_ok=True)
|
||||||
config_path = os.path.join(working_dir, "local_config.py")
|
config_path = os.path.join(working_dir, "local_config.py")
|
||||||
shutil.copy(
|
shutil.copy(
|
||||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
|
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
|
||||||
config_path,
|
|
||||||
)
|
)
|
||||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||||
else:
|
else:
|
||||||
os._exit(1)
|
os._exit(1)
|
||||||
|
# 加载配置文件
|
||||||
# 加载配置文件 (check_mode 跳过)
|
|
||||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||||
if not check_mode:
|
|
||||||
load_config_from_file(config_path)
|
load_config_from_file(config_path)
|
||||||
|
|
||||||
# 根据配置重新设置日志级别
|
# 根据配置重新设置日志级别
|
||||||
@@ -431,22 +218,19 @@ def main():
|
|||||||
|
|
||||||
if hasattr(BasicConfig, "log_level"):
|
if hasattr(BasicConfig, "log_level"):
|
||||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||||
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||||
if file_path is not None:
|
|
||||||
logger.info(f"[LOG_FILE] {file_path}")
|
|
||||||
|
|
||||||
if args.addr != parser.get_default("addr"):
|
if args_dict["addr"] == "test":
|
||||||
if args.addr == "test":
|
|
||||||
print_status("使用测试环境地址", "info")
|
print_status("使用测试环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
HTTPConfig.remote_addr = "https://uni-lab.test.bohrium.com/api/v1"
|
||||||
elif args.addr == "uat":
|
elif args_dict["addr"] == "uat":
|
||||||
print_status("使用uat环境地址", "info")
|
print_status("使用uat环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
HTTPConfig.remote_addr = "https://uni-lab.uat.bohrium.com/api/v1"
|
||||||
elif args.addr == "local":
|
elif args_dict["addr"] == "local":
|
||||||
print_status("使用本地环境地址", "info")
|
print_status("使用本地环境地址", "info")
|
||||||
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
HTTPConfig.remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||||
else:
|
else:
|
||||||
HTTPConfig.remote_addr = args.addr
|
HTTPConfig.remote_addr = args_dict.get("addr", "")
|
||||||
|
|
||||||
# 设置BasicConfig参数
|
# 设置BasicConfig参数
|
||||||
if args_dict.get("ak", ""):
|
if args_dict.get("ak", ""):
|
||||||
@@ -455,12 +239,9 @@ def main():
|
|||||||
if args_dict.get("sk", ""):
|
if args_dict.get("sk", ""):
|
||||||
BasicConfig.sk = args_dict.get("sk", "")
|
BasicConfig.sk = args_dict.get("sk", "")
|
||||||
print_status("传入了sk参数,优先采用传入参数!", "info")
|
print_status("传入了sk参数,优先采用传入参数!", "info")
|
||||||
BasicConfig.working_dir = working_dir
|
|
||||||
|
|
||||||
workflow_upload = args_dict.get("command") in ("workflow_upload", "wf")
|
|
||||||
|
|
||||||
# 使用远程资源启动
|
# 使用远程资源启动
|
||||||
if not workflow_upload and args_dict["use_remote_resource"]:
|
if args_dict["use_remote_resource"]:
|
||||||
print_status("使用远程资源启动", "info")
|
print_status("使用远程资源启动", "info")
|
||||||
from unilabos.app.web import http_client
|
from unilabos.app.web import http_client
|
||||||
|
|
||||||
@@ -473,84 +254,37 @@ def main():
|
|||||||
|
|
||||||
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
BasicConfig.port = args_dict["port"] if args_dict["port"] else BasicConfig.port
|
||||||
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
BasicConfig.disable_browser = args_dict["disable_browser"] or BasicConfig.disable_browser
|
||||||
|
BasicConfig.working_dir = working_dir
|
||||||
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.test_mode = args_dict.get("test_mode", False)
|
|
||||||
if BasicConfig.test_mode:
|
|
||||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
|
||||||
BasicConfig.extra_resource = args_dict.get("extra_resource", False)
|
|
||||||
if BasicConfig.extra_resource:
|
|
||||||
print_status("启用额外资源加载:将加载lab_开头的labware资源定义", "info")
|
|
||||||
BasicConfig.communication_protocol = "websocket"
|
BasicConfig.communication_protocol = "websocket"
|
||||||
machine_name = platform.node()
|
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])
|
||||||
BasicConfig.machine_name = machine_name
|
BasicConfig.machine_name = machine_name
|
||||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||||
BasicConfig.check_mode = check_mode
|
|
||||||
|
|
||||||
from unilabos.registry.registry import build_registry
|
|
||||||
|
|
||||||
# 显示启动横幅
|
|
||||||
print_unilab_banner(args_dict)
|
|
||||||
|
|
||||||
# Step 0: AST 分析优先 + YAML 注册表加载
|
|
||||||
# check_mode 和 upload_registry 都会执行实际 import 验证
|
|
||||||
devices_dirs = args_dict.get("devices", None)
|
|
||||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
|
||||||
external_only = args_dict.get("external_devices_only", False)
|
|
||||||
lab_registry = build_registry(
|
|
||||||
registry_paths=args_dict["registry_path"],
|
|
||||||
devices_dirs=devices_dirs,
|
|
||||||
upload_registry=BasicConfig.upload_registry,
|
|
||||||
check_mode=check_mode,
|
|
||||||
complete_registry=complete_registry,
|
|
||||||
external_only=external_only,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check mode: 注册表验证完成后直接退出
|
|
||||||
if check_mode:
|
|
||||||
device_count = len(lab_registry.device_type_registry)
|
|
||||||
resource_count = len(lab_registry.resource_type_registry)
|
|
||||||
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
# 以下导入依赖 ROS2 环境,check_mode 已退出不需要
|
|
||||||
from unilabos.resources.graphio import (
|
from unilabos.resources.graphio import (
|
||||||
read_node_link_json,
|
read_node_link_json,
|
||||||
read_graphml,
|
read_graphml,
|
||||||
dict_from_graph,
|
dict_from_graph,
|
||||||
modify_to_backend_format,
|
|
||||||
)
|
)
|
||||||
from unilabos.app.communication import get_communication_client
|
from unilabos.app.communication import get_communication_client
|
||||||
|
from unilabos.registry.registry import build_registry
|
||||||
from unilabos.app.backend import start_backend
|
from unilabos.app.backend import start_backend
|
||||||
from unilabos.app.web import http_client
|
from unilabos.app.web import http_client
|
||||||
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.resource_tracker import ResourceTreeSet, ResourceDict
|
from unilabos.resources.graphio import modify_to_backend_format
|
||||||
|
from unilabos.ros.nodes.resource_tracker import ResourceTreeSet, ResourceDict
|
||||||
|
|
||||||
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
|
# 显示启动横幅
|
||||||
if BasicConfig.upload_registry:
|
print_unilab_banner(args_dict)
|
||||||
if BasicConfig.ak and BasicConfig.sk:
|
|
||||||
# print_status("开始注册设备到服务端...", "info")
|
|
||||||
try:
|
|
||||||
register_devices_and_resources(lab_registry)
|
|
||||||
# print_status("设备注册完成", "info")
|
|
||||||
except Exception as e:
|
|
||||||
print_status(f"设备注册失败: {e}", "error")
|
|
||||||
else:
|
|
||||||
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
|
||||||
else:
|
|
||||||
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
|
||||||
|
|
||||||
# 处理 workflow_upload 子命令
|
# 注册表
|
||||||
if workflow_upload:
|
lab_registry = build_registry(
|
||||||
from unilabos.workflow.wf_utils import handle_workflow_upload_command
|
args_dict["registry_path"], args_dict.get("complete_registry", False), args_dict["upload_registry"]
|
||||||
|
)
|
||||||
handle_workflow_upload_command(args_dict)
|
|
||||||
print_status("工作流上传完成,程序退出", "info")
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
if not BasicConfig.ak or not BasicConfig.sk:
|
if not BasicConfig.ak or not BasicConfig.sk:
|
||||||
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
print_status("后续运行必须拥有一个实验室,请前往 https://uni-lab.bohrium.com 注册实验室!", "warning")
|
||||||
@@ -593,10 +327,6 @@ def main():
|
|||||||
for ind, i in enumerate(resource_edge_info[::-1]):
|
for ind, i in enumerate(resource_edge_info[::-1]):
|
||||||
source_node: ResourceDict = nodes[i["source"]]
|
source_node: ResourceDict = nodes[i["source"]]
|
||||||
target_node: ResourceDict = nodes[i["target"]]
|
target_node: ResourceDict = nodes[i["target"]]
|
||||||
if "sourceHandle" not in source_node:
|
|
||||||
continue
|
|
||||||
if "targetHandle" not in target_node:
|
|
||||||
continue
|
|
||||||
source_handle = i["sourceHandle"]
|
source_handle = i["sourceHandle"]
|
||||||
target_handle = i["targetHandle"]
|
target_handle = i["targetHandle"]
|
||||||
source_handler_keys = [
|
source_handler_keys = [
|
||||||
@@ -621,21 +351,31 @@ def main():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 如果从远端获取了物料信息,则与本地物料进行同步
|
# 如果从远端获取了物料信息,则与本地物料进行同步
|
||||||
if file_path is not None and 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_dict_list(request_startup_json["nodes"])
|
remote_tree_set = ResourceTreeSet.from_raw_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")
|
||||||
|
|
||||||
# 第二次设备包依赖检查:云端物料同步后,community 包可能引入新的 requirements
|
|
||||||
# TODO: 当 community device package 功能上线后,在这里调用
|
|
||||||
# install_requirements_txt(community_pkg_path / "requirements.txt", label="community.xxx")
|
|
||||||
|
|
||||||
# 使用 ResourceTreeSet 代替 list
|
# 使用 ResourceTreeSet 代替 list
|
||||||
args_dict["resources_config"] = resource_tree_set
|
args_dict["resources_config"] = resource_tree_set
|
||||||
args_dict["devices_config"] = resource_tree_set
|
args_dict["devices_config"] = resource_tree_set
|
||||||
args_dict["graph"] = graph_res.physical_setup_graph
|
args_dict["graph"] = graph_res.physical_setup_graph
|
||||||
|
|
||||||
|
if BasicConfig.upload_registry:
|
||||||
|
# 设备注册到服务端 - 需要 ak 和 sk
|
||||||
|
if BasicConfig.ak and BasicConfig.sk:
|
||||||
|
print_status("开始注册设备到服务端...", "info")
|
||||||
|
try:
|
||||||
|
register_devices_and_resources(lab_registry)
|
||||||
|
print_status("设备注册完成", "info")
|
||||||
|
except Exception as e:
|
||||||
|
print_status(f"设备注册失败: {e}", "error")
|
||||||
|
else:
|
||||||
|
print_status("未提供 ak 和 sk,跳过设备注册", "info")
|
||||||
|
else:
|
||||||
|
print_status("本次启动注册表不报送云端,如果您需要联网调试,请在启动命令增加--upload_registry", "warning")
|
||||||
|
|
||||||
if args_dict["controllers"] is not None:
|
if args_dict["controllers"] is not None:
|
||||||
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
|
args_dict["controllers_config"] = yaml.safe_load(open(args_dict["controllers"], encoding="utf-8"))
|
||||||
else:
|
else:
|
||||||
@@ -650,7 +390,6 @@ def main():
|
|||||||
comm_client = get_communication_client()
|
comm_client = get_communication_client()
|
||||||
if "websocket" in args_dict["app_bridges"]:
|
if "websocket" in args_dict["app_bridges"]:
|
||||||
args_dict["bridges"].append(comm_client)
|
args_dict["bridges"].append(comm_client)
|
||||||
|
|
||||||
def _exit(signum, frame):
|
def _exit(signum, frame):
|
||||||
comm_client.stop()
|
comm_client.stop()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
@@ -692,13 +431,16 @@ def main():
|
|||||||
resource_visualization.start()
|
resource_visualization.start()
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if "AMENT_PREFIX_PATH" in str(e):
|
if "AMENT_PREFIX_PATH" in str(e):
|
||||||
print_status(f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}", "warning")
|
print_status(
|
||||||
|
f"ROS 2环境未正确设置,跳过3D可视化启动。错误详情: {e}",
|
||||||
|
"warning"
|
||||||
|
)
|
||||||
print_status(
|
print_status(
|
||||||
"建议解决方案:\n"
|
"建议解决方案:\n"
|
||||||
"1. 激活Conda环境: conda activate unilab\n"
|
"1. 激活Conda环境: conda activate unilab\n"
|
||||||
"2. 或使用 --backend simple 参数\n"
|
"2. 或使用 --backend simple 参数\n"
|
||||||
"3. 或使用 --visual disable 参数禁用可视化",
|
"3. 或使用 --visual disable 参数禁用可视化",
|
||||||
"info",
|
"info"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
@@ -706,26 +448,16 @@ def main():
|
|||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
else:
|
else:
|
||||||
start_backend(**args_dict)
|
start_backend(**args_dict)
|
||||||
restart_requested = start_server(
|
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,
|
||||||
)
|
)
|
||||||
if restart_requested:
|
|
||||||
print_status("[Main] Restart requested, cleaning up...", "info")
|
|
||||||
cleanup_for_restart()
|
|
||||||
os._exit(RESTART_EXIT_CODE)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ class JobAddReq(BaseModel):
|
|||||||
action_type: str = Field(
|
action_type: str = Field(
|
||||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||||
)
|
)
|
||||||
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
|
|
||||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Optional, Tuple, Dict, Any
|
||||||
|
|
||||||
from unilabos.utils.log import logger
|
from unilabos.utils.log import logger
|
||||||
from unilabos.utils.tools import normalize_json as _normalize_device
|
from unilabos.utils.type_check import TypeEncoder
|
||||||
|
|
||||||
|
|
||||||
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]:
|
||||||
@@ -10,63 +11,50 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
|||||||
注册设备和资源到服务器(仅支持HTTP)
|
注册设备和资源到服务器(仅支持HTTP)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# 注册资源信息 - 使用HTTP方式
|
||||||
from unilabos.app.web.client import http_client
|
from unilabos.app.web.client import http_client
|
||||||
|
|
||||||
logger.info("[UniLab Register] 开始注册设备和资源...")
|
logger.info("[UniLab Register] 开始注册设备和资源...")
|
||||||
|
|
||||||
|
# 注册设备信息
|
||||||
devices_to_register = {}
|
devices_to_register = {}
|
||||||
for device_info in lab_registry.obtain_registry_device_info():
|
for device_info in lab_registry.obtain_registry_device_info():
|
||||||
devices_to_register[device_info["id"]] = _normalize_device(device_info)
|
devices_to_register[device_info["id"]] = json.loads(
|
||||||
logger.trace(f"[UniLab Register] 收集设备: {device_info['id']}")
|
json.dumps(device_info, ensure_ascii=False, cls=TypeEncoder)
|
||||||
|
)
|
||||||
|
logger.debug(f"[UniLab Register] 收集设备: {device_info['id']}")
|
||||||
|
|
||||||
resources_to_register = {}
|
resources_to_register = {}
|
||||||
for resource_info in lab_registry.obtain_registry_resource_info():
|
for resource_info in lab_registry.obtain_registry_resource_info():
|
||||||
resources_to_register[resource_info["id"]] = resource_info
|
resources_to_register[resource_info["id"]] = resource_info
|
||||||
logger.trace(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
logger.debug(f"[UniLab Register] 收集资源: {resource_info['id']}")
|
||||||
|
|
||||||
if gather_only:
|
if gather_only:
|
||||||
return devices_to_register, resources_to_register
|
return devices_to_register, resources_to_register
|
||||||
|
# 注册设备
|
||||||
if devices_to_register:
|
if devices_to_register:
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = http_client.resource_registry(
|
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||||
{"resources": list(devices_to_register.values())},
|
|
||||||
tag="device_registry",
|
|
||||||
)
|
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
res_data = response.json() if response.status_code == 200 else {}
|
if response.status_code in [200, 201]:
|
||||||
skipped = res_data.get("data", {}).get("skipped", False)
|
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
||||||
if skipped:
|
|
||||||
logger.info(
|
|
||||||
f"[UniLab Register] 设备注册跳过(内容未变化)"
|
|
||||||
f" {len(devices_to_register)} 个 {cost_time:.3f}s"
|
|
||||||
)
|
|
||||||
elif response.status_code in [200, 201]:
|
|
||||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time:.3f}s")
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||||
|
|
||||||
|
# 注册资源
|
||||||
if resources_to_register:
|
if resources_to_register:
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = http_client.resource_registry(
|
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||||
{"resources": list(resources_to_register.values())},
|
|
||||||
tag="resource_registry",
|
|
||||||
)
|
|
||||||
cost_time = time.time() - start_time
|
cost_time = time.time() - start_time
|
||||||
res_data = response.json() if response.status_code == 200 else {}
|
if response.status_code in [200, 201]:
|
||||||
skipped = res_data.get("data", {}).get("skipped", False)
|
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
||||||
if skipped:
|
|
||||||
logger.info(
|
|
||||||
f"[UniLab Register] 资源注册跳过(内容未变化)"
|
|
||||||
f" {len(resources_to_register)} 个 {cost_time:.3f}s"
|
|
||||||
)
|
|
||||||
elif response.status_code in [200, 201]:
|
|
||||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time:.3f}s")
|
|
||||||
else:
|
else:
|
||||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time:.3f}s")
|
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||||
|
|
||||||
|
logger.info("[UniLab Register] 设备和资源注册完成.")
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
"""
|
|
||||||
UniLabOS 应用工具函数
|
|
||||||
|
|
||||||
提供清理、重启等工具函数
|
|
||||||
"""
|
|
||||||
|
|
||||||
import glob
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def patch_rclpy_dll_windows():
|
|
||||||
"""在 Windows + conda 环境下为 rclpy 打 DLL 加载补丁"""
|
|
||||||
if sys.platform != "win32" or not os.environ.get("CONDA_PREFIX"):
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
import rclpy
|
|
||||||
|
|
||||||
return
|
|
||||||
except ImportError as e:
|
|
||||||
if not str(e).startswith("DLL load failed"):
|
|
||||||
return
|
|
||||||
cp = os.environ["CONDA_PREFIX"]
|
|
||||||
impl = os.path.join(cp, "Lib", "site-packages", "rclpy", "impl", "implementation_singleton.py")
|
|
||||||
pyd = glob.glob(os.path.join(cp, "Lib", "site-packages", "rclpy", "_rclpy_pybind11*.pyd"))
|
|
||||||
if not os.path.exists(impl) or not pyd:
|
|
||||||
return
|
|
||||||
with open(impl, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
lib_bin = os.path.join(cp, "Library", "bin").replace("\\", "/")
|
|
||||||
patch = f'# UniLabOS DLL Patch\nimport os,ctypes\nos.add_dll_directory("{lib_bin}") if hasattr(os,"add_dll_directory") else None\ntry: ctypes.CDLL("{pyd[0].replace(chr(92),"/")}")\nexcept: pass\n# End Patch\n'
|
|
||||||
shutil.copy2(impl, impl + ".bak")
|
|
||||||
with open(impl, "w", encoding="utf-8") as f:
|
|
||||||
f.write(patch + content)
|
|
||||||
|
|
||||||
|
|
||||||
patch_rclpy_dll_windows()
|
|
||||||
|
|
||||||
import gc
|
|
||||||
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
|
|
||||||
@@ -1052,7 +1052,7 @@ async def handle_file_import(websocket: WebSocket, request_data: dict):
|
|||||||
"result": {},
|
"result": {},
|
||||||
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
"schema": lab_registry._generate_unilab_json_command_schema(v["args"], k),
|
||||||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||||||
"handles": {},
|
"handles": [],
|
||||||
}
|
}
|
||||||
# 不生成已配置action的动作
|
# 不生成已配置action的动作
|
||||||
for k, v in enhanced_info["action_methods"].items()
|
for k, v in enhanced_info["action_methods"].items()
|
||||||
@@ -1340,5 +1340,5 @@ def setup_api_routes(app):
|
|||||||
# 启动广播任务
|
# 启动广播任务
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
asyncio.create_task(broadcast_device_status(), name="web-api-startup-device")
|
asyncio.create_task(broadcast_device_status())
|
||||||
asyncio.create_task(broadcast_status_page_data(), name="web-api-startup-status")
|
asyncio.create_task(broadcast_status_page_data())
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ HTTP客户端模块
|
|||||||
|
|
||||||
提供与远程服务器通信的客户端功能,只有host需要用
|
提供与远程服务器通信的客户端功能,只有host需要用
|
||||||
"""
|
"""
|
||||||
import gzip
|
|
||||||
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
|
||||||
|
|
||||||
from unilabos.utils.tools import fast_dumps as _fast_dumps, fast_dumps_pretty as _fast_dumps_pretty
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
from unilabos.ros.nodes.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
|
||||||
@@ -76,8 +76,7 @@ class HTTPClient:
|
|||||||
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
Dict[str, str]: 旧UUID到新UUID的映射关系 {old_uuid: new_uuid}
|
||||||
"""
|
"""
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "w", encoding="utf-8") as f:
|
||||||
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}
|
f.write(json.dumps({"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, indent=4))
|
||||||
f.write(json.dumps(payload, indent=4))
|
|
||||||
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
# 从序列化数据中提取所有节点的UUID(保存旧UUID)
|
||||||
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
old_uuids = {n.res_content.uuid: n for n in resources.all_nodes}
|
||||||
if not self.initialized or first_add:
|
if not self.initialized or first_add:
|
||||||
@@ -282,60 +281,24 @@ class HTTPClient:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def resource_registry(
|
def resource_registry(self, registry_data: Dict[str, Any] | List[Dict[str, Any]]) -> requests.Response:
|
||||||
self, registry_data: Dict[str, Any] | List[Dict[str, Any]], tag: str = "registry",
|
|
||||||
) -> requests.Response:
|
|
||||||
"""
|
"""
|
||||||
注册资源到服务器,同步保存请求/响应到 unilabos_data
|
注册资源到服务器
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
registry_data: 注册表数据,格式为 {resource_id: resource_info} / [{resource_info}]
|
||||||
tag: 保存文件的标签后缀 (如 "device_registry" / "resource_registry")
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: API响应对象
|
Response: API响应对象
|
||||||
"""
|
"""
|
||||||
# 序列化一次,同时用于保存和发送
|
|
||||||
json_bytes = _fast_dumps(registry_data)
|
|
||||||
|
|
||||||
# 保存请求数据到 unilabos_data
|
|
||||||
req_path = os.path.join(BasicConfig.working_dir, f"req_{tag}_upload.json")
|
|
||||||
try:
|
|
||||||
os.makedirs(BasicConfig.working_dir, exist_ok=True)
|
|
||||||
with open(req_path, "wb") as f:
|
|
||||||
f.write(_fast_dumps_pretty(registry_data))
|
|
||||||
logger.trace(f"注册表请求数据已保存: {req_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"保存注册表请求数据失败: {e}")
|
|
||||||
|
|
||||||
compressed_body = gzip.compress(json_bytes)
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Lab {self.auth}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Content-Encoding": "gzip",
|
|
||||||
}
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{self.remote_addr}/lab/resource",
|
f"{self.remote_addr}/lab/resource",
|
||||||
data=compressed_body,
|
json=registry_data,
|
||||||
headers=headers,
|
headers={"Authorization": f"Lab {self.auth}"},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 保存响应数据到 unilabos_data
|
|
||||||
res_path = os.path.join(BasicConfig.working_dir, f"res_{tag}_upload.json")
|
|
||||||
try:
|
|
||||||
with open(res_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(f"{response.status_code}\n{response.text}")
|
|
||||||
logger.trace(f"注册表响应数据已保存: {res_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"保存注册表响应数据失败: {e}")
|
|
||||||
|
|
||||||
if response.status_code not in [200, 201]:
|
if response.status_code not in [200, 201]:
|
||||||
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
logger.error(f"注册资源失败: {response.status_code}, {response.text}")
|
||||||
if response.status_code == 200:
|
|
||||||
res = response.json()
|
|
||||||
if "code" in res and res["code"] != 0:
|
|
||||||
logger.error(f"注册资源失败: {response.text}")
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def request_startup_json(self) -> Optional[Dict[str, Any]]:
|
def request_startup_json(self) -> Optional[Dict[str, Any]]:
|
||||||
@@ -368,106 +331,6 @@ class HTTPClient:
|
|||||||
logger.error(f"响应内容: {response.text}")
|
logger.error(f"响应内容: {response.text}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def workflow_import(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
workflow_uuid: str,
|
|
||||||
workflow_name: str,
|
|
||||||
nodes: List[Dict[str, Any]],
|
|
||||||
edges: List[Dict[str, Any]],
|
|
||||||
tags: Optional[List[str]] = None,
|
|
||||||
published: bool = False,
|
|
||||||
description: str = "",
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: 工作流名称(顶层)
|
|
||||||
workflow_uuid: 工作流UUID
|
|
||||||
workflow_name: 工作流名称(data内部)
|
|
||||||
nodes: 工作流节点列表
|
|
||||||
edges: 工作流边列表
|
|
||||||
tags: 工作流标签列表,默认为空列表
|
|
||||||
published: 是否发布工作流,默认为False
|
|
||||||
description: 工作流描述,发布时使用
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
|
||||||
"""
|
|
||||||
payload = {
|
|
||||||
"name": name,
|
|
||||||
"data": {
|
|
||||||
"workflow_uuid": workflow_uuid,
|
|
||||||
"workflow_name": workflow_name,
|
|
||||||
"nodes": nodes,
|
|
||||||
"edges": edges,
|
|
||||||
"tags": tags if tags is not None else [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
# 保存请求到文件
|
|
||||||
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps(payload, indent=4, ensure_ascii=False))
|
|
||||||
|
|
||||||
response = requests.post(
|
|
||||||
f"{self.remote_addr}/lab/workflow/owner/import",
|
|
||||||
json=payload,
|
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
# 保存响应到文件
|
|
||||||
with open(os.path.join(BasicConfig.working_dir, "res_workflow_upload.json"), "w", encoding="utf-8") as f:
|
|
||||||
f.write(f"{response.status_code}" + "\n" + response.text)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
res = response.json()
|
|
||||||
if "code" in res and res["code"] != 0:
|
|
||||||
logger.error(f"导入工作流失败: {response.text}")
|
|
||||||
return res
|
|
||||||
# 导入成功后,如果需要发布则额外发起发布请求
|
|
||||||
if published:
|
|
||||||
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
|
|
||||||
publish_res = self.workflow_publish(imported_uuid, description)
|
|
||||||
res["publish_result"] = publish_res
|
|
||||||
return res
|
|
||||||
else:
|
|
||||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
|
||||||
return {"code": response.status_code, "message": response.text}
|
|
||||||
|
|
||||||
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
发布工作流
|
|
||||||
|
|
||||||
Args:
|
|
||||||
workflow_uuid: 工作流UUID
|
|
||||||
description: 工作流描述
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict: API响应数据
|
|
||||||
"""
|
|
||||||
payload = {
|
|
||||||
"uuid": workflow_uuid,
|
|
||||||
"description": description,
|
|
||||||
"published": True,
|
|
||||||
}
|
|
||||||
logger.info(f"正在发布工作流: {workflow_uuid}")
|
|
||||||
response = requests.patch(
|
|
||||||
f"{self.remote_addr}/lab/workflow/owner",
|
|
||||||
json=payload,
|
|
||||||
headers={"Authorization": f"Lab {self.auth}"},
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
|
||||||
res = response.json()
|
|
||||||
if "code" in res and res["code"] != 0:
|
|
||||||
logger.error(f"发布工作流失败: {response.text}")
|
|
||||||
else:
|
|
||||||
logger.info(f"工作流发布成功: {workflow_uuid}")
|
|
||||||
return res
|
|
||||||
else:
|
|
||||||
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
|
|
||||||
return {"code": response.status_code, "message": response.text}
|
|
||||||
|
|
||||||
|
|
||||||
# 创建默认客户端实例
|
# 创建默认客户端实例
|
||||||
http_client = HTTPClient()
|
http_client = HTTPClient()
|
||||||
|
|||||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
|||||||
feedback=feedback or {},
|
feedback=feedback or {},
|
||||||
timestamp=time.time(),
|
timestamp=time.time(),
|
||||||
)
|
)
|
||||||
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||||
|
|
||||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||||
"""获取并删除任务结果"""
|
"""获取并删除任务结果"""
|
||||||
with self._results_lock:
|
with self._results_lock:
|
||||||
result = self._results.pop(job_id, None)
|
result = self._results.pop(job_id, None)
|
||||||
if result:
|
if result:
|
||||||
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||||
@@ -327,7 +327,6 @@ def job_add(req: JobAddReq) -> JobData:
|
|||||||
queue_item,
|
queue_item,
|
||||||
action_type=action_type,
|
action_type=action_type,
|
||||||
action_kwargs=action_args,
|
action_kwargs=action_args,
|
||||||
sample_material=req.sample_material,
|
|
||||||
server_info=server_info,
|
server_info=server_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
||||||
@@ -86,7 +87,7 @@ def setup_server() -> FastAPI:
|
|||||||
# 设置页面路由
|
# 设置页面路由
|
||||||
try:
|
try:
|
||||||
setup_web_pages(pages)
|
setup_web_pages(pages)
|
||||||
# info("[Web] 已加载Web UI模块")
|
info("[Web] 已加载Web UI模块")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
info(f"[Web] 未找到Web页面模块: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -95,7 +96,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) -> bool:
|
def start_server(host: str = "0.0.0.0", port: int = 8002, open_browser: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
启动服务器
|
启动服务器
|
||||||
|
|
||||||
@@ -103,14 +104,7 @@ 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()
|
||||||
|
|
||||||
@@ -129,37 +123,7 @@ 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
|
|
||||||
|
|
||||||
|
|
||||||
# 当脚本直接运行时启动服务器
|
# 当脚本直接运行时启动服务器
|
||||||
|
|||||||
@@ -23,10 +23,9 @@ from typing import Optional, Dict, Any, List
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from typing_extensions import TypedDict
|
from jedi.inference.gradual.typing import TypedDict
|
||||||
|
|
||||||
from unilabos.app.model import JobAddReq
|
from unilabos.app.model import JobAddReq
|
||||||
from unilabos.resources.resource_tracker import ResourceDictType
|
|
||||||
from unilabos.ros.nodes.presets.host_node import HostNode
|
from unilabos.ros.nodes.presets.host_node import HostNode
|
||||||
from unilabos.utils.type_check import serialize_result_info
|
from unilabos.utils.type_check import serialize_result_info
|
||||||
from unilabos.app.communication import BaseCommunicationClient
|
from unilabos.app.communication import BaseCommunicationClient
|
||||||
@@ -77,7 +76,6 @@ class JobInfo:
|
|||||||
start_time: float
|
start_time: float
|
||||||
last_update_time: float = field(default_factory=time.time)
|
last_update_time: float = field(default_factory=time.time)
|
||||||
ready_timeout: Optional[float] = None # READY状态的超时时间
|
ready_timeout: Optional[float] = None # READY状态的超时时间
|
||||||
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
|
|
||||||
|
|
||||||
def update_timestamp(self):
|
def update_timestamp(self):
|
||||||
"""更新最后更新时间"""
|
"""更新最后更新时间"""
|
||||||
@@ -129,15 +127,6 @@ class DeviceActionManager:
|
|||||||
# 总是将job添加到all_jobs中
|
# 总是将job添加到all_jobs中
|
||||||
self.all_jobs[job_info.job_id] = job_info
|
self.all_jobs[job_info.job_id] = job_info
|
||||||
|
|
||||||
# always_free的动作不受排队限制,直接设为READY
|
|
||||||
if job_info.always_free:
|
|
||||||
job_info.status = JobStatus.READY
|
|
||||||
job_info.update_timestamp()
|
|
||||||
job_info.set_ready_timeout(10)
|
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
|
||||||
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 检查是否有正在执行或准备执行的任务
|
# 检查是否有正在执行或准备执行的任务
|
||||||
if device_key in self.active_jobs:
|
if device_key in self.active_jobs:
|
||||||
# 有正在执行或准备执行的任务,加入队列
|
# 有正在执行或准备执行的任务,加入队列
|
||||||
@@ -165,7 +154,7 @@ class DeviceActionManager:
|
|||||||
job_info.set_ready_timeout(10) # 设置10秒超时
|
job_info.set_ready_timeout(10) # 设置10秒超时
|
||||||
self.active_jobs[device_key] = job_info
|
self.active_jobs[device_key] = job_info
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def start_job(self, job_id: str) -> bool:
|
def start_job(self, job_id: str) -> bool:
|
||||||
@@ -187,13 +176,9 @@ class DeviceActionManager:
|
|||||||
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# always_free的job不需要检查active_jobs
|
|
||||||
if not job_info.always_free:
|
|
||||||
# 检查设备上是否是这个job
|
# 检查设备上是否是这个job
|
||||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||||
job_log = format_job_log(
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
|
||||||
)
|
|
||||||
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -218,13 +203,6 @@ class DeviceActionManager:
|
|||||||
job_info = self.all_jobs[job_id]
|
job_info = self.all_jobs[job_id]
|
||||||
device_key = job_info.device_action_key
|
device_key = job_info.device_action_key
|
||||||
|
|
||||||
# always_free的job直接清理,不影响队列
|
|
||||||
if job_info.always_free:
|
|
||||||
job_info.status = JobStatus.ENDED
|
|
||||||
job_info.update_timestamp()
|
|
||||||
del self.all_jobs[job_id]
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 移除活跃任务
|
# 移除活跃任务
|
||||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||||
del self.active_jobs[device_key]
|
del self.active_jobs[device_key]
|
||||||
@@ -232,9 +210,8 @@ class DeviceActionManager:
|
|||||||
job_info.update_timestamp()
|
job_info.update_timestamp()
|
||||||
# 从all_jobs中移除已结束的job
|
# 从all_jobs中移除已结束的job
|
||||||
del self.all_jobs[job_id]
|
del self.all_jobs[job_id]
|
||||||
# job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
# logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
logger.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
||||||
@@ -250,20 +227,15 @@ class DeviceActionManager:
|
|||||||
next_job_log = format_job_log(
|
next_job_log = format_job_log(
|
||||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||||
)
|
)
|
||||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||||
return next_job
|
return next_job
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_active_jobs(self) -> List[JobInfo]:
|
def get_active_jobs(self) -> List[JobInfo]:
|
||||||
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
|
"""获取所有正在执行的任务"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
jobs = list(self.active_jobs.values())
|
return list(self.active_jobs.values())
|
||||||
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
|
|
||||||
for job in self.all_jobs.values():
|
|
||||||
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
|
|
||||||
jobs.append(job)
|
|
||||||
return jobs
|
|
||||||
|
|
||||||
def get_queued_jobs(self) -> List[JobInfo]:
|
def get_queued_jobs(self) -> List[JobInfo]:
|
||||||
"""获取所有排队中的任务"""
|
"""获取所有排队中的任务"""
|
||||||
@@ -288,14 +260,6 @@ class DeviceActionManager:
|
|||||||
job_info = self.all_jobs[job_id]
|
job_info = self.all_jobs[job_id]
|
||||||
device_key = job_info.device_action_key
|
device_key = job_info.device_action_key
|
||||||
|
|
||||||
# always_free的job直接清理
|
|
||||||
if job_info.always_free:
|
|
||||||
job_info.status = JobStatus.ENDED
|
|
||||||
del self.all_jobs[job_id]
|
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
|
||||||
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 如果是正在执行的任务
|
# 如果是正在执行的任务
|
||||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||||
# 清理active job状态
|
# 清理active job状态
|
||||||
@@ -304,7 +268,7 @@ class DeviceActionManager:
|
|||||||
# 从all_jobs中移除
|
# 从all_jobs中移除
|
||||||
del self.all_jobs[job_id]
|
del self.all_jobs[job_id]
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
logger.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||||
|
|
||||||
# 启动下一个任务
|
# 启动下一个任务
|
||||||
if device_key in self.device_queues and self.device_queues[device_key]:
|
if device_key in self.device_queues and self.device_queues[device_key]:
|
||||||
@@ -317,7 +281,7 @@ class DeviceActionManager:
|
|||||||
next_job_log = format_job_log(
|
next_job_log = format_job_log(
|
||||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||||
)
|
)
|
||||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 如果是排队中的任务
|
# 如果是排队中的任务
|
||||||
@@ -331,7 +295,7 @@ class DeviceActionManager:
|
|||||||
job_log = format_job_log(
|
job_log = format_job_log(
|
||||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||||
)
|
)
|
||||||
logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
@@ -369,18 +333,13 @@ class DeviceActionManager:
|
|||||||
timeout_jobs = []
|
timeout_jobs = []
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
|
# 统计READY状态的任务数量
|
||||||
ready_candidates = list(self.active_jobs.values())
|
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
|
||||||
for job in self.all_jobs.values():
|
|
||||||
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
|
|
||||||
ready_candidates.append(job)
|
|
||||||
|
|
||||||
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
|
|
||||||
if ready_jobs_count > 0:
|
if ready_jobs_count > 0:
|
||||||
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
||||||
|
|
||||||
# 找到所有超时的READY任务(只检测,不处理)
|
# 找到所有超时的READY任务(只检测,不处理)
|
||||||
for job_info in ready_candidates:
|
for job_info in self.active_jobs.values():
|
||||||
if job_info.is_ready_timeout():
|
if job_info.is_ready_timeout():
|
||||||
timeout_jobs.append(job_info)
|
timeout_jobs.append(job_info)
|
||||||
job_log = format_job_log(
|
job_log = format_job_log(
|
||||||
@@ -400,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 = str(uuid.uuid4())[:6] # 产生一个随机的session_id
|
self.session_id = ""
|
||||||
|
|
||||||
# WebSocket连接
|
# WebSocket连接
|
||||||
self.websocket = None
|
self.websocket = None
|
||||||
@@ -409,7 +368,6 @@ class MessageProcessor:
|
|||||||
# 线程控制
|
# 线程控制
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.thread = None
|
self.thread = None
|
||||||
self._loop = None # asyncio event loop引用,用于外部关闭websocket
|
|
||||||
self.reconnect_count = 0
|
self.reconnect_count = 0
|
||||||
|
|
||||||
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
logger.info(f"[MessageProcessor] Initialized for URL: {websocket_url}")
|
||||||
@@ -436,31 +394,22 @@ class MessageProcessor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止消息处理线程"""
|
"""停止消息处理线程"""
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
# 主动关闭websocket以快速中断消息接收循环
|
|
||||||
ws = self.websocket
|
|
||||||
loop = self._loop
|
|
||||||
if ws and loop and loop.is_running():
|
|
||||||
try:
|
|
||||||
asyncio.run_coroutine_threadsafe(ws.close(), loop)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if self.thread and self.thread.is_alive():
|
if self.thread and self.thread.is_alive():
|
||||||
self.thread.join(timeout=2)
|
self.thread.join(timeout=2)
|
||||||
logger.info("[MessageProcessor] Stopped")
|
logger.info("[MessageProcessor] Stopped")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""运行消息处理主循环"""
|
"""运行消息处理主循环"""
|
||||||
self._loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
asyncio.set_event_loop(self._loop)
|
asyncio.set_event_loop(loop)
|
||||||
self._loop.run_until_complete(self._connection_handler())
|
loop.run_until_complete(self._connection_handler())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
logger.error(f"[MessageProcessor] Thread error: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
if self._loop:
|
if loop:
|
||||||
self._loop.close()
|
loop.close()
|
||||||
self._loop = None
|
|
||||||
|
|
||||||
async def _connection_handler(self):
|
async def _connection_handler(self):
|
||||||
"""处理WebSocket连接和重连逻辑"""
|
"""处理WebSocket连接和重连逻辑"""
|
||||||
@@ -477,10 +426,8 @@ class MessageProcessor:
|
|||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
self.websocket_url,
|
self.websocket_url,
|
||||||
ssl=ssl_context,
|
ssl=ssl_context,
|
||||||
open_timeout=20,
|
|
||||||
ping_interval=WSConfig.ping_interval,
|
ping_interval=WSConfig.ping_interval,
|
||||||
ping_timeout=10,
|
ping_timeout=10,
|
||||||
close_timeout=5,
|
|
||||||
additional_headers={
|
additional_headers={
|
||||||
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
"Authorization": f"Lab {BasicConfig.auth_secret()}",
|
||||||
"EdgeSession": f"{self.session_id}",
|
"EdgeSession": f"{self.session_id}",
|
||||||
@@ -491,98 +438,72 @@ class MessageProcessor:
|
|||||||
self.connected = True
|
self.connected = True
|
||||||
self.reconnect_count = 0
|
self.reconnect_count = 0
|
||||||
|
|
||||||
logger.info(f"[MessageProcessor] 已连接到 {self.websocket_url}")
|
logger.info(f"[MessageProcessor] Connected to {self.websocket_url}")
|
||||||
|
|
||||||
# 启动发送协程
|
# 启动发送协程
|
||||||
send_task = asyncio.create_task(self._send_handler(), name="websocket-send_task")
|
send_task = asyncio.create_task(self._send_handler())
|
||||||
|
|
||||||
# 每次连接(含重连)后重新向服务端注册,
|
|
||||||
# 否则服务端不知道客户端已上线,不会推送消息。
|
|
||||||
if self.websocket_client:
|
|
||||||
self.websocket_client.publish_host_ready()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 接收消息循环
|
# 接收消息循环
|
||||||
await self._message_handler()
|
await self._message_handler()
|
||||||
finally:
|
finally:
|
||||||
# 必须在 async with __aexit__ 之前停止 send_task,
|
|
||||||
# 否则 send_task 会在关闭握手期间继续发送数据,
|
|
||||||
# 干扰 websockets 库的内部清理,导致 task 泄漏。
|
|
||||||
self.connected = False
|
|
||||||
send_task.cancel()
|
send_task.cancel()
|
||||||
try:
|
try:
|
||||||
await send_task
|
await send_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
except websockets.exceptions.ConnectionClosed:
|
except websockets.exceptions.ConnectionClosed:
|
||||||
logger.warning("[MessageProcessor] 与服务端连接中断")
|
logger.warning("[MessageProcessor] Connection closed")
|
||||||
except TimeoutError:
|
|
||||||
logger.warning(
|
|
||||||
f"[MessageProcessor] 与服务端连接通信超时 (已尝试 {self.reconnect_count + 1} 次),请检查您的网络状况"
|
|
||||||
)
|
|
||||||
except websockets.exceptions.InvalidStatus as e:
|
|
||||||
logger.warning(
|
|
||||||
f"[MessageProcessor] 收到服务端注册码 {e.response.status_code}, 上一进程可能还未退出"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
logger.error(f"[MessageProcessor] 尝试重连时出错 {str(e)}")
|
|
||||||
finally:
|
|
||||||
self.connected = False
|
self.connected = False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MessageProcessor] Connection error: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
self.connected = False
|
||||||
|
finally:
|
||||||
self.websocket = None
|
self.websocket = None
|
||||||
|
|
||||||
# 重连逻辑
|
# 重连逻辑
|
||||||
if not self.is_running:
|
if self.is_running and self.reconnect_count < WSConfig.max_reconnect_attempts:
|
||||||
break
|
|
||||||
if self.reconnect_count < WSConfig.max_reconnect_attempts:
|
|
||||||
self.reconnect_count += 1
|
self.reconnect_count += 1
|
||||||
backoff = WSConfig.reconnect_interval
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[MessageProcessor] 即将在 {backoff} 秒后重连 (已尝试 {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
f"[MessageProcessor] Reconnecting in {WSConfig.reconnect_interval}s "
|
||||||
|
f"(attempt {self.reconnect_count}/{WSConfig.max_reconnect_attempts})"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(backoff)
|
await asyncio.sleep(WSConfig.reconnect_interval)
|
||||||
else:
|
elif self.reconnect_count >= WSConfig.max_reconnect_attempts:
|
||||||
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
logger.error("[MessageProcessor] Max reconnection attempts reached")
|
||||||
break
|
break
|
||||||
|
else:
|
||||||
|
self.reconnect_count -= 1
|
||||||
|
|
||||||
async def _message_handler(self):
|
async def _message_handler(self):
|
||||||
"""处理接收到的消息。
|
"""处理接收到的消息"""
|
||||||
|
|
||||||
ConnectionClosed 不在此处捕获,让其向上传播到 _connection_handler,
|
|
||||||
以便 async with websockets.connect() 的 __aexit__ 能感知连接已断,
|
|
||||||
正确清理内部 task,避免 task 泄漏。
|
|
||||||
"""
|
|
||||||
if not self.websocket:
|
if not self.websocket:
|
||||||
logger.error("[MessageProcessor] WebSocket connection is None")
|
logger.error("[MessageProcessor] WebSocket connection is None")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
async for message in self.websocket:
|
async for message in self.websocket:
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
message_type = data.get("action", "")
|
await self._process_message(data)
|
||||||
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:
|
||||||
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
logger.error(f"[MessageProcessor] Error processing message: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
except websockets.exceptions.ConnectionClosed:
|
||||||
|
logger.info("[MessageProcessor] Message handler stopped - connection closed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[MessageProcessor] Message handler error: {str(e)}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
async def _send_handler(self):
|
async def _send_handler(self):
|
||||||
"""处理发送队列中的消息"""
|
"""处理发送队列中的消息"""
|
||||||
logger.trace("[MessageProcessor] Send handler started")
|
logger.debug("[MessageProcessor] Send handler started")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while self.connected and self.websocket:
|
while self.connected and self.websocket:
|
||||||
@@ -610,7 +531,7 @@ class MessageProcessor:
|
|||||||
try:
|
try:
|
||||||
message_str = json.dumps(msg, ensure_ascii=False)
|
message_str = json.dumps(msg, ensure_ascii=False)
|
||||||
await self.websocket.send(message_str)
|
await self.websocket.send(message_str)
|
||||||
# logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
@@ -627,16 +548,18 @@ class MessageProcessor:
|
|||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.debug("[MessageProcessor] Send handler cancelled")
|
logger.debug("[MessageProcessor] Send handler cancelled")
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
logger.error(f"[MessageProcessor] Fatal error in send handler: {str(e)}")
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
logger.debug("[MessageProcessor] Send handler stopped")
|
logger.debug("[MessageProcessor] Send handler stopped")
|
||||||
|
|
||||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
async def _process_message(self, data: Dict[str, Any]):
|
||||||
"""处理收到的消息"""
|
"""处理收到的消息"""
|
||||||
logger.trace(f"[MessageProcessor] Processing message: {message_type}")
|
message_type = data.get("action", "")
|
||||||
|
message_data = data.get("data")
|
||||||
|
|
||||||
|
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if message_type == "pong":
|
if message_type == "pong":
|
||||||
@@ -648,23 +571,14 @@ 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 == "add_device":
|
|
||||||
await self._handle_device_manage(message_data, "add")
|
|
||||||
elif message_type == "remove_device":
|
|
||||||
await self._handle_device_manage(message_data, "remove")
|
|
||||||
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}")
|
||||||
|
|
||||||
@@ -678,24 +592,6 @@ class MessageProcessor:
|
|||||||
if host_node:
|
if host_node:
|
||||||
host_node.handle_pong_response(pong_data)
|
host_node.handle_pong_response(pong_data)
|
||||||
|
|
||||||
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
|
|
||||||
"""检查该action是否标记为always_free,通过HostNode统一的_action_value_mappings查找"""
|
|
||||||
try:
|
|
||||||
host_node = HostNode.get_instance(0)
|
|
||||||
if not host_node:
|
|
||||||
return False
|
|
||||||
# noinspection PyProtectedMember
|
|
||||||
action_mappings = host_node._action_value_mappings.get(device_id)
|
|
||||||
if not action_mappings:
|
|
||||||
return False
|
|
||||||
# 尝试直接匹配或 auto- 前缀匹配
|
|
||||||
for key in [action_name, f"auto-{action_name}"]:
|
|
||||||
if key in action_mappings:
|
|
||||||
return action_mappings[key].get("always_free", False)
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
||||||
"""处理query_action_state消息"""
|
"""处理query_action_state消息"""
|
||||||
device_id = data.get("device_id", "")
|
device_id = data.get("device_id", "")
|
||||||
@@ -710,9 +606,6 @@ class MessageProcessor:
|
|||||||
|
|
||||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||||
|
|
||||||
# 检查action是否为always_free
|
|
||||||
action_always_free = self._check_action_always_free(device_id, action_name)
|
|
||||||
|
|
||||||
# 创建任务信息
|
# 创建任务信息
|
||||||
job_info = JobInfo(
|
job_info = JobInfo(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -722,7 +615,6 @@ class MessageProcessor:
|
|||||||
device_action_key=device_action_key,
|
device_action_key=device_action_key,
|
||||||
status=JobStatus.QUEUE,
|
status=JobStatus.QUEUE,
|
||||||
start_time=time.time(),
|
start_time=time.time(),
|
||||||
always_free=action_always_free,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 添加到设备管理器
|
# 添加到设备管理器
|
||||||
@@ -734,13 +626,13 @@ class MessageProcessor:
|
|||||||
await self._send_action_state_response(
|
await self._send_action_state_response(
|
||||||
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
||||||
)
|
)
|
||||||
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
|
logger.info(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||||
else:
|
else:
|
||||||
# 需要排队
|
# 需要排队
|
||||||
await self._send_action_state_response(
|
await self._send_action_state_response(
|
||||||
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
||||||
)
|
)
|
||||||
logger.trace(f"[MessageProcessor] Job {job_log} queued")
|
logger.info(f"[MessageProcessor] Job {job_log} queued")
|
||||||
|
|
||||||
# 通知QueueProcessor有新的队列更新
|
# 通知QueueProcessor有新的队列更新
|
||||||
if self.queue_processor:
|
if self.queue_processor:
|
||||||
@@ -749,8 +641,6 @@ class MessageProcessor:
|
|||||||
async def _handle_job_start(self, data: Dict[str, Any]):
|
async def _handle_job_start(self, data: Dict[str, Any]):
|
||||||
"""处理job_start消息"""
|
"""处理job_start消息"""
|
||||||
try:
|
try:
|
||||||
if not data.get("sample_material"):
|
|
||||||
data["sample_material"] = {}
|
|
||||||
req = JobAddReq(**data)
|
req = JobAddReq(**data)
|
||||||
|
|
||||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||||
@@ -782,7 +672,6 @@ class MessageProcessor:
|
|||||||
queue_item,
|
queue_item,
|
||||||
action_type=req.action_type,
|
action_type=req.action_type,
|
||||||
action_kwargs=req.action_args,
|
action_kwargs=req.action_args,
|
||||||
sample_material=req.sample_material,
|
|
||||||
server_info=req.server_info,
|
server_info=req.server_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -947,7 +836,9 @@ class MessageProcessor:
|
|||||||
device_action_groups[key_add] = []
|
device_action_groups[key_add] = []
|
||||||
device_action_groups[key_add].append(item["uuid"])
|
device_action_groups[key_add].append(item["uuid"])
|
||||||
|
|
||||||
logger.info(f"[资源同步] 跨站Transfer: {item['uuid'][:8]} from {device_old_id} to {device_id}")
|
logger.info(
|
||||||
|
f"[MessageProcessor] Resource migrated: {item['uuid'][:8]} from {device_old_id} to {device_id}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 正常update
|
# 正常update
|
||||||
key = (device_id, "update")
|
key = (device_id, "update")
|
||||||
@@ -961,13 +852,11 @@ class MessageProcessor:
|
|||||||
device_action_groups[key] = []
|
device_action_groups[key] = []
|
||||||
device_action_groups[key].append(item["uuid"])
|
device_action_groups[key].append(item["uuid"])
|
||||||
|
|
||||||
logger.trace(
|
logger.info(f"触发物料更新 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}")
|
||||||
f"[资源同步] 动作 {action} 分组数量: {len(device_action_groups)}, 总数量: {len(resource_uuid_list)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 为每个(device_id, action)创建独立的更新线程
|
# 为每个(device_id, action)创建独立的更新线程
|
||||||
for (device_id, actual_action), items in device_action_groups.items():
|
for (device_id, actual_action), items in device_action_groups.items():
|
||||||
logger.trace(f"[资源同步] {device_id} 物料动作 {actual_action} 数量: {len(items)}")
|
logger.info(f"设备 {device_id} 物料更新 {actual_action} 数量: {len(items)}")
|
||||||
|
|
||||||
def _notify_resource_tree(dev_id, act, item_list):
|
def _notify_resource_tree(dev_id, act, item_list):
|
||||||
try:
|
try:
|
||||||
@@ -999,81 +888,6 @@ class MessageProcessor:
|
|||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
async def _handle_device_manage(self, device_list: list[ResourceDictType], action: str):
|
|
||||||
"""Handle add_device / remove_device from LabGo server."""
|
|
||||||
if not device_list:
|
|
||||||
return
|
|
||||||
|
|
||||||
for item in device_list:
|
|
||||||
target_node_id = item.get("target_node_id", "host_node")
|
|
||||||
|
|
||||||
def _notify(target_id: str, act: str, cfg: ResourceDictType):
|
|
||||||
try:
|
|
||||||
host_node = HostNode.get_instance(timeout=5)
|
|
||||||
if not host_node:
|
|
||||||
logger.error(f"[DeviceManage] HostNode not available for {act}_device")
|
|
||||||
return
|
|
||||||
success = host_node.notify_device_manage(target_id, act, cfg)
|
|
||||||
if success:
|
|
||||||
logger.info(f"[DeviceManage] {act}_device completed on {target_id}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"[DeviceManage] {act}_device failed on {target_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[DeviceManage] Error in {act}_device: {e}")
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
thread = threading.Thread(
|
|
||||||
target=_notify,
|
|
||||||
args=(target_node_id, action, item),
|
|
||||||
daemon=True,
|
|
||||||
name=f"DeviceManage-{action}-{item.get('id', '')}",
|
|
||||||
)
|
|
||||||
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")
|
|
||||||
|
|
||||||
# 发送确认消息
|
|
||||||
self.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
|
||||||
):
|
):
|
||||||
@@ -1145,14 +959,13 @@ class QueueProcessor:
|
|||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止队列处理线程"""
|
"""停止队列处理线程"""
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.queue_update_event.set() # 立即唤醒等待中的线程
|
|
||||||
if self.thread and self.thread.is_alive():
|
if self.thread and self.thread.is_alive():
|
||||||
self.thread.join(timeout=2)
|
self.thread.join(timeout=2)
|
||||||
logger.info("[QueueProcessor] Stopped")
|
logger.info("[QueueProcessor] Stopped")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
"""运行队列处理主循环"""
|
"""运行队列处理主循环"""
|
||||||
logger.trace("[QueueProcessor] Queue processor started")
|
logger.debug("[QueueProcessor] Queue processor started")
|
||||||
|
|
||||||
while self.is_running:
|
while self.is_running:
|
||||||
try:
|
try:
|
||||||
@@ -1246,11 +1059,6 @@ class QueueProcessor:
|
|||||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
||||||
|
|
||||||
for job_info in queued_jobs:
|
for job_info in queued_jobs:
|
||||||
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
|
||||||
# 此时不应再发送 busy/need_more,否则会覆盖已发出的 free=True 通知
|
|
||||||
if job_info.status != JobStatus.QUEUE:
|
|
||||||
continue
|
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
"action": "report_action_state",
|
"action": "report_action_state",
|
||||||
"data": {
|
"data": {
|
||||||
@@ -1266,7 +1074,7 @@ class QueueProcessor:
|
|||||||
success = self.message_processor.send_message(message)
|
success = self.message_processor.send_message(message)
|
||||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||||
if success:
|
if success:
|
||||||
logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
||||||
|
|
||||||
@@ -1289,7 +1097,7 @@ class QueueProcessor:
|
|||||||
job_info.action_name,
|
job_info.action_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||||
|
|
||||||
# 结束任务,获取下一个可执行的任务
|
# 结束任务,获取下一个可执行的任务
|
||||||
next_job = self.device_manager.end_job(job_id)
|
next_job = self.device_manager.end_job(job_id)
|
||||||
@@ -1309,8 +1117,8 @@ class QueueProcessor:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||||
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||||
|
|
||||||
# 立即触发下一轮状态检查
|
# 立即触发下一轮状态检查
|
||||||
self.notify_queue_update()
|
self.notify_queue_update()
|
||||||
@@ -1367,6 +1175,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
else:
|
else:
|
||||||
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
url = f"{scheme}://{parsed.netloc}/api/v1/ws/schedule"
|
||||||
|
|
||||||
|
logger.debug(f"[WebSocketClient] URL: {url}")
|
||||||
return url
|
return url
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
@@ -1379,11 +1188,13 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
logger.error("[WebSocketClient] WebSocket URL not configured")
|
logger.error("[WebSocketClient] WebSocket URL not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(f"[WebSocketClient] Starting connection to {self.websocket_url}")
|
||||||
|
|
||||||
# 启动两个核心线程
|
# 启动两个核心线程
|
||||||
self.message_processor.start()
|
self.message_processor.start()
|
||||||
self.queue_processor.start()
|
self.queue_processor.start()
|
||||||
|
|
||||||
logger.trace("[WebSocketClient] All threads started")
|
logger.info("[WebSocketClient] All threads started")
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""停止WebSocket客户端"""
|
"""停止WebSocket客户端"""
|
||||||
@@ -1399,8 +1210,8 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
message = {"action": "normal_exit", "data": {"session_id": session_id}}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
logger.info(f"[WebSocketClient] Sent normal_exit message with session_id: {session_id}")
|
||||||
# send_handler 每100ms检查一次队列,等300ms足以让消息发出
|
# 给一点时间让消息发送出去
|
||||||
time.sleep(0.3)
|
time.sleep(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
logger.warning(f"[WebSocketClient] Failed to send normal_exit message: {str(e)}")
|
||||||
|
|
||||||
@@ -1432,7 +1243,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
self.message_processor.send_message(message)
|
self.message_processor.send_message(message)
|
||||||
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
logger.debug(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
|
||||||
@@ -1452,7 +1263,7 @@ class WebSocketClient(BaseCommunicationClient):
|
|||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError):
|
||||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||||
|
|
||||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
logger.info(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||||
|
|
||||||
# 通知队列处理器job完成(包括timeout的job)
|
# 通知队列处理器job完成(包括timeout的job)
|
||||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||||
@@ -1474,7 +1285,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.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
|
logger.debug(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消息"""
|
||||||
@@ -1505,59 +1316,17 @@ 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(f"[WebSocketClient] Host node ready signal published with {len(devices)} devices")
|
logger.info("[WebSocketClient] Host node ready signal published")
|
||||||
|
|||||||
@@ -95,29 +95,8 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
|||||||
return total_volume
|
return total_volume
|
||||||
|
|
||||||
|
|
||||||
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
def is_integrated_pump(node_name):
|
||||||
"""
|
return "pump" in node_name and "valve" in node_name
|
||||||
判断是否为泵阀一体设备
|
|
||||||
"""
|
|
||||||
class_lower = (node_class or "").lower()
|
|
||||||
name_lower = (node_name or "").lower()
|
|
||||||
|
|
||||||
if "pump" not in class_lower and "pump" not in name_lower:
|
|
||||||
return False
|
|
||||||
|
|
||||||
integrated_markers = [
|
|
||||||
"valve",
|
|
||||||
"pump_valve",
|
|
||||||
"pumpvalve",
|
|
||||||
"integrated",
|
|
||||||
"transfer_pump",
|
|
||||||
]
|
|
||||||
|
|
||||||
for marker in integrated_markers:
|
|
||||||
if marker in class_lower or marker in name_lower:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def find_connected_pump(G, valve_node):
|
def find_connected_pump(G, valve_node):
|
||||||
@@ -207,9 +186,7 @@ def build_pump_valve_maps(G, pump_backbone):
|
|||||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||||
|
|
||||||
for node in filtered_backbone:
|
for node in filtered_backbone:
|
||||||
node_data = G.nodes.get(node, {})
|
if is_integrated_pump(G.nodes[node]["class"]):
|
||||||
node_class = node_data.get("class", "") or ""
|
|
||||||
if is_integrated_pump(node_class, node):
|
|
||||||
pumps_from_node[node] = node
|
pumps_from_node[node] = node
|
||||||
valve_from_node[node] = node
|
valve_from_node[node] = node
|
||||||
debug_print(f" - 集成泵-阀: {node}")
|
debug_print(f" - 集成泵-阀: {node}")
|
||||||
|
|||||||
@@ -16,17 +16,12 @@ 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 # 填写绝对路径
|
||||||
disable_browser = False # 禁止浏览器自动打开
|
disable_browser = False # 禁止浏览器自动打开
|
||||||
port = 8002 # 本地HTTP服务
|
port = 8002 # 本地HTTP服务
|
||||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
log_level: Literal['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = "DEBUG" # 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||||
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
|
||||||
extra_resource = False # 是否加载lab_开头的额外资源
|
|
||||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
|
||||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def auth_secret(cls):
|
def auth_secret(cls):
|
||||||
@@ -41,12 +36,12 @@ class BasicConfig:
|
|||||||
class WSConfig:
|
class WSConfig:
|
||||||
reconnect_interval = 5 # 重连间隔(秒)
|
reconnect_interval = 5 # 重连间隔(秒)
|
||||||
max_reconnect_attempts = 999 # 最大重连次数
|
max_reconnect_attempts = 999 # 最大重连次数
|
||||||
ping_interval = 20 # ping间隔(秒)
|
ping_interval = 30 # ping间隔(秒)
|
||||||
|
|
||||||
|
|
||||||
# HTTP配置
|
# HTTP配置
|
||||||
class HTTPConfig:
|
class HTTPConfig:
|
||||||
remote_addr = "https://uni-lab.bohrium.com/api/v1"
|
remote_addr = "http://127.0.0.1:48197/api/v1"
|
||||||
|
|
||||||
|
|
||||||
# ROS配置
|
# ROS配置
|
||||||
@@ -70,7 +65,6 @@ def _update_config_from_module(module):
|
|||||||
if not attr.startswith("_"):
|
if not attr.startswith("_"):
|
||||||
setattr(obj, attr, getattr(getattr(module, name), attr))
|
setattr(obj, attr, getattr(getattr(module, name), attr))
|
||||||
|
|
||||||
|
|
||||||
def _update_config_from_env():
|
def _update_config_from_env():
|
||||||
prefix = "UNILABOS_"
|
prefix = "UNILABOS_"
|
||||||
for env_key, env_value in os.environ.items():
|
for env_key, env_value in os.environ.items():
|
||||||
@@ -147,5 +141,5 @@ def load_config(config_path=None):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
exit(1)
|
exit(1)
|
||||||
else:
|
else:
|
||||||
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
|
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
|
||||||
load_config(config_path)
|
load_config(config_path)
|
||||||
|
|||||||
@@ -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.resources.resource_tracker import DeviceNodeResourceTracker
|
from unilabos.ros.nodes.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.resources.resource_tracker import DeviceNodeResourceTracker
|
from unilabos.ros.nodes.resource_tracker import DeviceNodeResourceTracker
|
||||||
resource_tracker = DeviceNodeResourceTracker()
|
resource_tracker = DeviceNodeResourceTracker()
|
||||||
|
|
||||||
# 初始化基类
|
# 初始化基类
|
||||||
|
|||||||
@@ -55,8 +55,6 @@ class OpcUaWorkflowModel(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
""" 前后端Json解析用 """
|
""" 前后端Json解析用 """
|
||||||
|
|
||||||
|
|
||||||
class NodeFunctionJson(BaseModel):
|
class NodeFunctionJson(BaseModel):
|
||||||
func_name: str
|
func_name: str
|
||||||
node_name: str
|
node_name: str
|
||||||
@@ -199,8 +197,7 @@ class BaseClient(UniversalDriver):
|
|||||||
# 对于方法节点,需要获取父节点ID
|
# 对于方法节点,需要获取父节点ID
|
||||||
parent_node = node.get_parent()
|
parent_node = node.get_parent()
|
||||||
parent_node_id = str(parent_node.nodeid)
|
parent_node_id = str(parent_node.nodeid)
|
||||||
self._node_registry[node_name] = Method(self.client, node_name, str(node.nodeid), parent_node_id,
|
self._node_registry[node_name] = Method(self.client, node_name, str(node.nodeid), parent_node_id, data_type)
|
||||||
data_type)
|
|
||||||
logger.info(f"找到方法节点: {node_name}")
|
logger.info(f"找到方法节点: {node_name}")
|
||||||
|
|
||||||
# 递归处理子节点
|
# 递归处理子节点
|
||||||
@@ -449,8 +446,7 @@ class BaseClient(UniversalDriver):
|
|||||||
|
|
||||||
function_name: Dict[str, Callable[[Callable[[str], OpcUaNodeBase]], bool]] = {}
|
function_name: Dict[str, Callable[[Callable[[str], OpcUaNodeBase]], bool]] = {}
|
||||||
|
|
||||||
def create_node_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None,
|
def create_node_function(self, func_name: str = None, node_name: str = None, mode: str = None, value: Any = None, **kwargs) -> Callable[[Callable[[str], OpcUaNodeBase]], bool]:
|
||||||
**kwargs) -> Callable[[Callable[[str], OpcUaNodeBase]], bool]:
|
|
||||||
def execute_node_function(use_node: Callable[[str], OpcUaNodeBase]) -> Union[bool, Tuple[Any, bool]]:
|
def execute_node_function(use_node: Callable[[str], OpcUaNodeBase]) -> Union[bool, Tuple[Any, bool]]:
|
||||||
target_node = use_node(node_name)
|
target_node = use_node(node_name)
|
||||||
|
|
||||||
@@ -546,8 +542,7 @@ class BaseClient(UniversalDriver):
|
|||||||
for node_name, node_value in write_nodes.items():
|
for node_name, node_value in write_nodes.items():
|
||||||
# 检查值是否是字符串类型的参数名
|
# 检查值是否是字符串类型的参数名
|
||||||
current_value = node_value
|
current_value = node_value
|
||||||
if isinstance(node_value, str) and hasattr(self,
|
if isinstance(node_value, str) and hasattr(self, '_workflow_params') and node_value in self._workflow_params:
|
||||||
'_workflow_params') and node_value in self._workflow_params:
|
|
||||||
current_value = self._workflow_params[node_value]
|
current_value = self._workflow_params[node_value]
|
||||||
print(f"初始化函数: 从参数获取值 {node_value} = {current_value}")
|
print(f"初始化函数: 从参数获取值 {node_value} = {current_value}")
|
||||||
|
|
||||||
@@ -666,9 +661,7 @@ class BaseClient(UniversalDriver):
|
|||||||
self.function_name[func_name] = execute_cleanup_function
|
self.function_name[func_name] = execute_cleanup_function
|
||||||
return execute_cleanup_function
|
return execute_cleanup_function
|
||||||
|
|
||||||
def create_start_function(self, func_name: str, stop_condition_expression: str = "True",
|
def create_start_function(self, func_name: str, stop_condition_expression: str = "True", write_nodes: Union[Dict[str, Any], List[str]] = None, condition_nodes: Union[Dict[str, str], List[str]] = None):
|
||||||
write_nodes: Union[Dict[str, Any], List[str]] = None,
|
|
||||||
condition_nodes: Union[Dict[str, str], List[str]] = None):
|
|
||||||
"""
|
"""
|
||||||
创建开始函数
|
创建开始函数
|
||||||
|
|
||||||
@@ -678,7 +671,6 @@ class BaseClient(UniversalDriver):
|
|||||||
write_nodes: 写节点配置,可以是节点名列表[节点1,节点2]或节点值映射{节点1:值1,节点2:值2}
|
write_nodes: 写节点配置,可以是节点名列表[节点1,节点2]或节点值映射{节点1:值1,节点2:值2}
|
||||||
condition_nodes: 条件节点列表 [节点名1, 节点名2]
|
condition_nodes: 条件节点列表 [节点名1, 节点名2]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def execute_start_function(use_node: Callable[[str], OpcUaNodeBase]) -> bool:
|
def execute_start_function(use_node: Callable[[str], OpcUaNodeBase]) -> bool:
|
||||||
# 直接处理写入节点
|
# 直接处理写入节点
|
||||||
if write_nodes:
|
if write_nodes:
|
||||||
@@ -1084,8 +1076,7 @@ class BaseClient(UniversalDriver):
|
|||||||
try:
|
try:
|
||||||
input_data = json.loads(json_input)
|
input_data = json.loads(json_input)
|
||||||
if not isinstance(input_data, dict):
|
if not isinstance(input_data, dict):
|
||||||
return json.dumps(
|
return json.dumps({"error": True, "error_message": "输入必须是包含node_name和value的JSON对象", "success": False})
|
||||||
{"error": True, "error_message": "输入必须是包含node_name和value的JSON对象", "success": False})
|
|
||||||
|
|
||||||
# 从JSON中提取节点名称和值
|
# 从JSON中提取节点名称和值
|
||||||
node_name = input_data.get("node_name")
|
node_name = input_data.get("node_name")
|
||||||
@@ -1137,8 +1128,7 @@ class BaseClient(UniversalDriver):
|
|||||||
|
|
||||||
|
|
||||||
class OpcUaClient(BaseClient):
|
class OpcUaClient(BaseClient):
|
||||||
def __init__(self, url: str, config_path: str = None, username: str = None, password: str = None,
|
def __init__(self, url: str, config_path: str = None, username: str = None, password: str = None, refresh_interval: float = 1.0):
|
||||||
refresh_interval: float = 1.0):
|
|
||||||
# 降低OPCUA库的日志级别
|
# 降低OPCUA库的日志级别
|
||||||
import logging
|
import logging
|
||||||
logging.getLogger("opcua").setLevel(logging.WARNING)
|
logging.getLogger("opcua").setLevel(logging.WARNING)
|
||||||
@@ -1188,7 +1178,6 @@ class OpcUaClient(BaseClient):
|
|||||||
# 缓存中没有则直接读取
|
# 缓存中没有则直接读取
|
||||||
value, _ = self.use_node(node_key).read()
|
value, _ = self.use_node(node_key).read()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
return getter
|
return getter
|
||||||
|
|
||||||
# 使用property装饰器将方法注册为类属性
|
# 使用property装饰器将方法注册为类属性
|
||||||
@@ -1343,7 +1332,6 @@ if __name__ == '__main__':
|
|||||||
|
|
||||||
# 使用配置文件创建客户端并自动注册工作流
|
# 使用配置文件创建客户端并自动注册工作流
|
||||||
import os
|
import os
|
||||||
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
config_path = os.path.join(current_dir, "opcua_huairou.json")
|
config_path = os.path.join(current_dir, "opcua_huairou.json")
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from enum import Enum
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Tuple, Union, Optional, Any, List
|
from typing import Tuple, Union, Optional, Any, List
|
||||||
|
|
||||||
from opcua import Client, Node, ua
|
from opcua import Client, Node
|
||||||
from opcua.ua import NodeId, NodeClass, VariantType
|
from opcua.ua import NodeId, NodeClass, VariantType
|
||||||
|
|
||||||
|
|
||||||
@@ -47,68 +47,23 @@ class Base(ABC):
|
|||||||
def _get_node(self) -> Node:
|
def _get_node(self) -> Node:
|
||||||
if self._node is None:
|
if self._node is None:
|
||||||
try:
|
try:
|
||||||
# 尝试多种 NodeId 字符串格式解析,兼容不同服务器/库的输出
|
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||||
# 可能的格式示例: 'ns=2;i=1234', 'ns=2;s=SomeString',
|
if "NumericNodeId" in self._node_id:
|
||||||
# 'StringNodeId(ns=4;s=OPC|变量名)', 'NumericNodeId(ns=2;i=1234)' 等
|
# 从字符串中提取命名空间和标识符
|
||||||
import re
|
import re
|
||||||
|
match = re.search(r'ns=(\d+);i=(\d+)', self._node_id)
|
||||||
nid = self._node_id
|
if match:
|
||||||
# 如果已经是 NodeId/Node 对象(库用户可能传入),直接使用
|
ns = int(match.group(1))
|
||||||
try:
|
identifier = int(match.group(2))
|
||||||
from opcua.ua import NodeId as UaNodeId
|
|
||||||
if isinstance(nid, UaNodeId):
|
|
||||||
self._node = self._client.get_node(nid)
|
|
||||||
return self._node
|
|
||||||
except Exception:
|
|
||||||
# 若导入或类型判断失败,则继续下一步
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 直接以字符串形式处理
|
|
||||||
if isinstance(nid, str):
|
|
||||||
nid = nid.strip()
|
|
||||||
|
|
||||||
# 处理包含类名的格式,如 'StringNodeId(ns=4;s=...)' 或 'NumericNodeId(ns=2;i=...)'
|
|
||||||
# 提取括号内的内容
|
|
||||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
|
||||||
if match_wrapped:
|
|
||||||
# 提取括号内的实际 node_id 字符串
|
|
||||||
nid = match_wrapped.group(2).strip()
|
|
||||||
|
|
||||||
# 常见短格式 'ns=2;i=1234' 或 'ns=2;s=SomeString'
|
|
||||||
if re.match(r'^ns=\d+;[is]=', nid):
|
|
||||||
self._node = self._client.get_node(nid)
|
|
||||||
else:
|
|
||||||
# 尝试提取 ns 和 i 或 s
|
|
||||||
# 对于字符串标识符,可能包含特殊字符,使用非贪婪匹配
|
|
||||||
m_num = re.search(r'ns=(\d+);i=(\d+)', nid)
|
|
||||||
m_str = re.search(r'ns=(\d+);s=(.+?)(?:\)|$)', nid)
|
|
||||||
if m_num:
|
|
||||||
ns = int(m_num.group(1))
|
|
||||||
identifier = int(m_num.group(2))
|
|
||||||
node_id = NodeId(identifier, ns)
|
node_id = NodeId(identifier, ns)
|
||||||
self._node = self._client.get_node(node_id)
|
self._node = self._client.get_node(node_id)
|
||||||
elif m_str:
|
|
||||||
ns = int(m_str.group(1))
|
|
||||||
identifier = m_str.group(2).strip()
|
|
||||||
# 对于字符串标识符,直接使用字符串格式
|
|
||||||
node_id_str = f"ns={ns};s={identifier}"
|
|
||||||
self._node = self._client.get_node(node_id_str)
|
|
||||||
else:
|
else:
|
||||||
# 回退:尝试直接传入字符串(有些实现接受其它格式)
|
raise ValueError(f"无法解析节点ID: {self._node_id}")
|
||||||
try:
|
|
||||||
self._node = self._client.get_node(self._node_id)
|
|
||||||
except Exception as e:
|
|
||||||
# 输出更详细的错误信息供调试
|
|
||||||
print(f"获取节点失败(尝试直接字符串): {self._node_id}, 错误: {e}")
|
|
||||||
raise
|
|
||||||
else:
|
else:
|
||||||
# 非字符串,尝试直接使用
|
# 直接使用节点ID字符串
|
||||||
self._node = self._client.get_node(self._node_id)
|
self._node = self._client.get_node(self._node_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"获取节点失败: {self._node_id}, 错误: {e}")
|
print(f"获取节点失败: {self._node_id}, 错误: {e}")
|
||||||
# 添加额外提示,帮助定位 BadNodeIdUnknown 问题
|
|
||||||
print("提示: 请确认该 node_id 是否来自当前连接的服务器地址空间," \
|
|
||||||
"以及 CSV/配置中名称与服务器 BrowseName 是否匹配。")
|
|
||||||
raise
|
raise
|
||||||
return self._node
|
return self._node
|
||||||
|
|
||||||
@@ -149,56 +104,7 @@ class Variable(Base):
|
|||||||
|
|
||||||
def write(self, value: Any) -> bool:
|
def write(self, value: Any) -> bool:
|
||||||
try:
|
try:
|
||||||
# 如果声明了数据类型,则尝试转换并使用对应的 Variant 写入
|
|
||||||
coerced = value
|
|
||||||
try:
|
|
||||||
if self._data_type is not None:
|
|
||||||
# 基于声明的数据类型做简单类型转换
|
|
||||||
dt = self._data_type
|
|
||||||
if dt in (DataType.SBYTE, DataType.BYTE, DataType.INT16, DataType.UINT16,
|
|
||||||
DataType.INT32, DataType.UINT32, DataType.INT64, DataType.UINT64):
|
|
||||||
# 数值类型 -> int
|
|
||||||
if isinstance(value, str):
|
|
||||||
coerced = int(value)
|
|
||||||
else:
|
|
||||||
coerced = int(value)
|
|
||||||
elif dt in (DataType.FLOAT, DataType.DOUBLE):
|
|
||||||
if isinstance(value, str):
|
|
||||||
coerced = float(value)
|
|
||||||
else:
|
|
||||||
coerced = float(value)
|
|
||||||
elif dt == DataType.BOOLEAN:
|
|
||||||
if isinstance(value, str):
|
|
||||||
v = value.strip().lower()
|
|
||||||
if v in ("true", "1", "yes", "on"):
|
|
||||||
coerced = True
|
|
||||||
elif v in ("false", "0", "no", "off"):
|
|
||||||
coerced = False
|
|
||||||
else:
|
|
||||||
coerced = bool(value)
|
|
||||||
else:
|
|
||||||
coerced = bool(value)
|
|
||||||
elif dt == DataType.STRING or dt == DataType.BYTESTRING or dt == DataType.DATETIME:
|
|
||||||
coerced = str(value)
|
|
||||||
|
|
||||||
# 使用 ua.Variant 明确指定 VariantType
|
|
||||||
try:
|
|
||||||
variant = ua.Variant(coerced, dt.value)
|
|
||||||
self._get_node().set_value(variant)
|
|
||||||
except Exception:
|
|
||||||
# 回退:有些 set_value 实现接受 (value, variant_type)
|
|
||||||
try:
|
|
||||||
self._get_node().set_value(coerced, dt.value)
|
|
||||||
except Exception:
|
|
||||||
# 最后回退到直接写入(保持兼容性)
|
|
||||||
self._get_node().set_value(coerced)
|
|
||||||
else:
|
|
||||||
# 未声明数据类型,直接写入
|
|
||||||
self._get_node().set_value(value)
|
self._get_node().set_value(value)
|
||||||
except Exception:
|
|
||||||
# 若在转换或按数据类型写入失败,尝试直接写入原始值并让上层捕获错误
|
|
||||||
self._get_node().set_value(value)
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"写入变量 {self._name} 失败: {e}")
|
print(f"写入变量 {self._name} 失败: {e}")
|
||||||
@@ -214,50 +120,20 @@ class Method(Base):
|
|||||||
def _get_parent_node(self) -> Node:
|
def _get_parent_node(self) -> Node:
|
||||||
if self._parent_node is None:
|
if self._parent_node is None:
|
||||||
try:
|
try:
|
||||||
# 处理父节点ID,使用与_get_node相同的解析逻辑
|
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||||
|
if "NumericNodeId" in self._parent_node_id:
|
||||||
|
# 从字符串中提取命名空间和标识符
|
||||||
import re
|
import re
|
||||||
|
match = re.search(r'ns=(\d+);i=(\d+)', self._parent_node_id)
|
||||||
nid = self._parent_node_id
|
if match:
|
||||||
|
ns = int(match.group(1))
|
||||||
# 如果已经是 NodeId 对象,直接使用
|
identifier = int(match.group(2))
|
||||||
try:
|
|
||||||
from opcua.ua import NodeId as UaNodeId
|
|
||||||
if isinstance(nid, UaNodeId):
|
|
||||||
self._parent_node = self._client.get_node(nid)
|
|
||||||
return self._parent_node
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 字符串处理
|
|
||||||
if isinstance(nid, str):
|
|
||||||
nid = nid.strip()
|
|
||||||
|
|
||||||
# 处理包含类名的格式
|
|
||||||
match_wrapped = re.match(r'(String|Numeric|Byte|Guid|TwoByteNode|FourByteNode)NodeId\((.*)\)', nid)
|
|
||||||
if match_wrapped:
|
|
||||||
nid = match_wrapped.group(2).strip()
|
|
||||||
|
|
||||||
# 常见短格式
|
|
||||||
if re.match(r'^ns=\d+;[is]=', nid):
|
|
||||||
self._parent_node = self._client.get_node(nid)
|
|
||||||
else:
|
|
||||||
# 提取 ns 和 i 或 s
|
|
||||||
m_num = re.search(r'ns=(\d+);i=(\d+)', nid)
|
|
||||||
m_str = re.search(r'ns=(\d+);s=(.+?)(?:\)|$)', nid)
|
|
||||||
if m_num:
|
|
||||||
ns = int(m_num.group(1))
|
|
||||||
identifier = int(m_num.group(2))
|
|
||||||
node_id = NodeId(identifier, ns)
|
node_id = NodeId(identifier, ns)
|
||||||
self._parent_node = self._client.get_node(node_id)
|
self._parent_node = self._client.get_node(node_id)
|
||||||
elif m_str:
|
|
||||||
ns = int(m_str.group(1))
|
|
||||||
identifier = m_str.group(2).strip()
|
|
||||||
node_id_str = f"ns={ns};s={identifier}"
|
|
||||||
self._parent_node = self._client.get_node(node_id_str)
|
|
||||||
else:
|
else:
|
||||||
# 回退
|
raise ValueError(f"无法解析父节点ID: {self._parent_node_id}")
|
||||||
self._parent_node = self._client.get_node(self._parent_node_id)
|
|
||||||
else:
|
else:
|
||||||
|
# 直接使用节点ID字符串
|
||||||
self._parent_node = self._client.get_node(self._parent_node_id)
|
self._parent_node = self._client.get_node(self._parent_node_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"获取父节点失败: {self._parent_node_id}, 错误: {e}")
|
print(f"获取父节点失败: {self._parent_node_id}, 错误: {e}")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import inspect
|
import inspect
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class ResourceVisualization:
|
|||||||
new_dev.set("device_name", node["id"]+"_")
|
new_dev.set("device_name", node["id"]+"_")
|
||||||
# if node["parent"] is not None:
|
# if node["parent"] is not None:
|
||||||
# new_dev.set("station_name", node["parent"]+'_')
|
# new_dev.set("station_name", node["parent"]+'_')
|
||||||
if "position" in node:
|
|
||||||
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
new_dev.set("x",str(float(node["position"]["position"]["x"])/1000))
|
||||||
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
new_dev.set("y",str(float(node["position"]["position"]["y"])/1000))
|
||||||
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
new_dev.set("z",str(float(node["position"]["position"]["z"])/1000))
|
||||||
@@ -136,13 +136,6 @@ class ResourceVisualization:
|
|||||||
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
|
new_dev.set("rx",str(float(node["config"]["rotation"]["x"])))
|
||||||
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
|
new_dev.set("ry",str(float(node["config"]["rotation"]["y"])))
|
||||||
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
|
new_dev.set("r",str(float(node["config"]["rotation"]["z"])))
|
||||||
if "pose" in node:
|
|
||||||
new_dev.set("x",str(float(node["pose"]["position"]["x"])/1000))
|
|
||||||
new_dev.set("y",str(float(node["pose"]["position"]["y"])/1000))
|
|
||||||
new_dev.set("z",str(float(node["pose"]["position"]["z"])/1000))
|
|
||||||
new_dev.set("rx",str(float(node["pose"]["rotation"]["x"])))
|
|
||||||
new_dev.set("ry",str(float(node["pose"]["rotation"]["y"])))
|
|
||||||
new_dev.set("r",str(float(node["pose"]["rotation"]["z"])))
|
|
||||||
if "device_config" in node["config"]:
|
if "device_config" in node["config"]:
|
||||||
for key, value in node["config"]["device_config"].items():
|
for key, value in node["config"]["device_config"].items():
|
||||||
new_dev.set(key, str(value))
|
new_dev.set(key, str(value))
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
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、语言优先
|
|
||||||
本准则同时具有中文版本和英文版本,如果英文版本和中文版本有冲突,以中文版本为准。
|
|
||||||
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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.
|
|
||||||
|
|
||||||
@@ -1,712 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import websockets
|
|
||||||
|
|
||||||
logging.getLogger("zeep").setLevel(logging.WARNING)
|
|
||||||
logging.getLogger("zeep.xsd.schema").setLevel(logging.WARNING)
|
|
||||||
logging.getLogger("zeep.xsd.schema.schema").setLevel(logging.WARNING)
|
|
||||||
from onvif import ONVIFCamera # 新增:ONVIF PTZ 控制
|
|
||||||
|
|
||||||
|
|
||||||
# ======================= 独立的 PTZController =======================
|
|
||||||
class PTZController:
|
|
||||||
def __init__(self, host: str, port: int, user: str, password: str):
|
|
||||||
"""
|
|
||||||
:param host: 摄像机 IP 或域名(和 RTSP 的一样即可)
|
|
||||||
:param port: ONVIF 端口(多数为 80,看你的设备)
|
|
||||||
:param user: 摄像机用户名
|
|
||||||
:param password: 摄像机密码
|
|
||||||
"""
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.user = user
|
|
||||||
self.password = password
|
|
||||||
|
|
||||||
self.cam: Optional[ONVIFCamera] = None
|
|
||||||
self.media_service = None
|
|
||||||
self.ptz_service = None
|
|
||||||
self.profile = None
|
|
||||||
|
|
||||||
def connect(self) -> bool:
|
|
||||||
"""
|
|
||||||
建立 ONVIF 连接并初始化 PTZ 能力,失败返回 False(不抛异常)
|
|
||||||
Note: 首先 pip install onvif-zeep
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self.cam = ONVIFCamera(self.host, self.port, self.user, self.password)
|
|
||||||
self.media_service = self.cam.create_media_service()
|
|
||||||
self.ptz_service = self.cam.create_ptz_service()
|
|
||||||
profiles = self.media_service.GetProfiles()
|
|
||||||
if not profiles:
|
|
||||||
print("[PTZ] No media profiles found on camera.", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
self.profile = profiles[0]
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[PTZ] Failed to init ONVIF PTZ: {e}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _continuous_move(self, pan: float, tilt: float, zoom: float, duration: float) -> bool:
|
|
||||||
"""
|
|
||||||
连续移动一段时间(秒),之后自动停止。
|
|
||||||
此函数为阻塞模式:只有在 Stop 调用结束后,才返回 True/False。
|
|
||||||
"""
|
|
||||||
if not self.ptz_service or not self.profile:
|
|
||||||
print("[PTZ] _continuous_move: ptz_service or profile not ready", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 进入前先强行停一下,避免前一次残留动作
|
|
||||||
self._force_stop()
|
|
||||||
|
|
||||||
req = self.ptz_service.create_type("ContinuousMove")
|
|
||||||
req.ProfileToken = self.profile.token
|
|
||||||
|
|
||||||
req.Velocity = {
|
|
||||||
"PanTilt": {"x": pan, "y": tilt},
|
|
||||||
"Zoom": {"x": zoom},
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
print(f"[PTZ] ContinuousMove start: pan={pan}, tilt={tilt}, zoom={zoom}, duration={duration}", file=sys.stderr)
|
|
||||||
self.ptz_service.ContinuousMove(req)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[PTZ] ContinuousMove failed: {e}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 阻塞等待:这里决定“运动时间”
|
|
||||||
import time
|
|
||||||
wait_seconds = max(2 * duration, 0.0)
|
|
||||||
time.sleep(wait_seconds)
|
|
||||||
|
|
||||||
# 运动完成后强制停止
|
|
||||||
return self._force_stop()
|
|
||||||
|
|
||||||
def stop(self) -> bool:
|
|
||||||
"""
|
|
||||||
阻塞调用 Stop(带重试),成功 True,失败 False。
|
|
||||||
"""
|
|
||||||
return self._force_stop()
|
|
||||||
|
|
||||||
# ------- 对外动作接口(给 CameraController 调用) -------
|
|
||||||
# 所有接口都为“阻塞模式”:只有在运动 + Stop 完成后才返回 True/False
|
|
||||||
|
|
||||||
def move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
|
||||||
print(f"[PTZ] move_up called, speed={speed}, duration={duration}", file=sys.stderr)
|
|
||||||
return self._continuous_move(pan=0.0, tilt=+speed, zoom=0.0, duration=duration)
|
|
||||||
|
|
||||||
def move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
|
||||||
print(f"[PTZ] move_down called, speed={speed}, duration={duration}", file=sys.stderr)
|
|
||||||
return self._continuous_move(pan=0.0, tilt=-speed, zoom=0.0, duration=duration)
|
|
||||||
|
|
||||||
def move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
|
||||||
print(f"[PTZ] move_left called, speed={speed}, duration={duration}", file=sys.stderr)
|
|
||||||
return self._continuous_move(pan=-speed, tilt=0.0, zoom=0.0, duration=duration)
|
|
||||||
|
|
||||||
def move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
|
||||||
print(f"[PTZ] move_right called, speed={speed}, duration={duration}", file=sys.stderr)
|
|
||||||
return self._continuous_move(pan=+speed, tilt=0.0, zoom=0.0, duration=duration)
|
|
||||||
|
|
||||||
# ------- 占位的变倍接口(当前设备不支持) -------
|
|
||||||
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
|
||||||
"""
|
|
||||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
|
||||||
"""
|
|
||||||
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
|
||||||
"""
|
|
||||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
|
||||||
"""
|
|
||||||
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _force_stop(self, retries: int = 3, delay: float = 0.1) -> bool:
|
|
||||||
"""
|
|
||||||
尝试多次调用 Stop,作为“强制停止”手段。
|
|
||||||
:param retries: 重试次数
|
|
||||||
:param delay: 每次重试间隔(秒)
|
|
||||||
"""
|
|
||||||
if not self.ptz_service or not self.profile:
|
|
||||||
print("[PTZ] _force_stop: ptz_service or profile not ready", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
import time
|
|
||||||
last_error = None
|
|
||||||
for i in range(retries):
|
|
||||||
try:
|
|
||||||
print(f"[PTZ] _force_stop: calling Stop(), attempt={i+1}", file=sys.stderr)
|
|
||||||
self.ptz_service.Stop({"ProfileToken": self.profile.token})
|
|
||||||
print("[PTZ] _force_stop: Stop() returned OK", file=sys.stderr)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
print(f"[PTZ] _force_stop: Stop() failed at attempt {i+1}: {e}", file=sys.stderr)
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
print(f"[PTZ] _force_stop: all {retries} attempts failed, last error: {last_error}", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ======================= CameraController(加入 PTZ) =======================
|
|
||||||
|
|
||||||
class CameraController:
|
|
||||||
"""
|
|
||||||
Uni-Lab-OS 摄像头驱动(driver 形式)
|
|
||||||
启动 Uni-Lab-OS 后,立即开始推流
|
|
||||||
|
|
||||||
- WebSocket 信令:通过 signal_backend_url 连接到后端
|
|
||||||
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
|
|
||||||
- 媒体服务器:通过 rtmp_url / webrtc_api / webrtc_stream_url
|
|
||||||
当前配置为 SRS,与独立 HostSimulator 独立运行脚本保持一致。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host_id: str = "demo-host",
|
|
||||||
|
|
||||||
# (1)信令后端(WebSocket)
|
|
||||||
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
|
|
||||||
|
|
||||||
# (2)媒体后端(RTMP + WebRTC API)
|
|
||||||
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
|
||||||
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
|
|
||||||
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
|
||||||
camera_rtsp_url: str = "",
|
|
||||||
|
|
||||||
# (3)PTZ 控制相关(ONVIF)
|
|
||||||
ptz_host: str = "", # 一般就是摄像头 IP,比如 "192.168.31.164"
|
|
||||||
ptz_port: int = 80, # ONVIF 端口,不一定是 80,按实际情况改
|
|
||||||
ptz_user: str = "", # admin
|
|
||||||
ptz_password: str = "", # admin123
|
|
||||||
):
|
|
||||||
self.host_id = host_id
|
|
||||||
self.camera_rtsp_url = camera_rtsp_url
|
|
||||||
|
|
||||||
# 拼接最终的 WebSocket URL:.../host/<host_id>
|
|
||||||
signal_backend_url = signal_backend_url.rstrip("/")
|
|
||||||
if not signal_backend_url.endswith("/host"):
|
|
||||||
signal_backend_url = signal_backend_url + "/host"
|
|
||||||
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
|
|
||||||
|
|
||||||
# 媒体服务器配置
|
|
||||||
self.rtmp_url = rtmp_url
|
|
||||||
self.webrtc_api = webrtc_api
|
|
||||||
self.webrtc_stream_url = webrtc_stream_url
|
|
||||||
|
|
||||||
# PTZ 控制
|
|
||||||
self.ptz_host = ptz_host
|
|
||||||
self.ptz_port = ptz_port
|
|
||||||
self.ptz_user = ptz_user
|
|
||||||
self.ptz_password = ptz_password
|
|
||||||
self._ptz: Optional[PTZController] = None
|
|
||||||
self._init_ptz_if_possible()
|
|
||||||
|
|
||||||
# 运行时状态
|
|
||||||
self._ws: Optional[object] = None
|
|
||||||
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
|
||||||
self._running = False
|
|
||||||
self._loop_task: Optional[asyncio.Future] = None
|
|
||||||
|
|
||||||
# 事件循环 & 线程
|
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
||||||
self._loop_thread: Optional[threading.Thread] = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.start()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
# ------------------------ PTZ 初始化 ------------------------
|
|
||||||
|
|
||||||
# ------------------------ PTZ 公开动作方法(一个动作一个函数) ------------------------
|
|
||||||
|
|
||||||
def ptz_move_up(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
|
||||||
print(f"[CameraController] ptz_move_up called, speed={speed}, duration={duration}")
|
|
||||||
return self._ptz.move_up(speed=speed, duration=duration)
|
|
||||||
|
|
||||||
def ptz_move_down(self, speed: float = 0.5, duration: float = 1.0) -> bool:
|
|
||||||
print(f"[CameraController] ptz_move_down called, speed={speed}, duration={duration}")
|
|
||||||
return self._ptz.move_down(speed=speed, duration=duration)
|
|
||||||
|
|
||||||
def ptz_move_left(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
|
||||||
print(f"[CameraController] ptz_move_left called, speed={speed}, duration={duration}")
|
|
||||||
return self._ptz.move_left(speed=speed, duration=duration)
|
|
||||||
|
|
||||||
def ptz_move_right(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
|
||||||
print(f"[CameraController] ptz_move_right called, speed={speed}, duration={duration}")
|
|
||||||
return self._ptz.move_right(speed=speed, duration=duration)
|
|
||||||
|
|
||||||
def zoom_in(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
|
||||||
"""
|
|
||||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
|
||||||
"""
|
|
||||||
print("[PTZ] zoom_in is disabled for this device.", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def zoom_out(self, speed: float = 0.2, duration: float = 1.0) -> bool:
|
|
||||||
"""
|
|
||||||
当前设备不支持变倍;保留方法只是避免上层调用时报错。
|
|
||||||
"""
|
|
||||||
print("[PTZ] zoom_out is disabled for this device.", file=sys.stderr)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def ptz_stop(self):
|
|
||||||
if self._ptz is None:
|
|
||||||
print("[CameraController] PTZ not initialized.", file=sys.stderr)
|
|
||||||
return
|
|
||||||
self._ptz.stop()
|
|
||||||
|
|
||||||
def _init_ptz_if_possible(self):
|
|
||||||
"""
|
|
||||||
根据 ptz_host / user / password 初始化 PTZ;
|
|
||||||
如果配置信息不全则不启用 PTZ(静默)。
|
|
||||||
"""
|
|
||||||
if not (self.ptz_host and self.ptz_user and self.ptz_password):
|
|
||||||
return
|
|
||||||
ctrl = PTZController(
|
|
||||||
host=self.ptz_host,
|
|
||||||
port=self.ptz_port,
|
|
||||||
user=self.ptz_user,
|
|
||||||
password=self.ptz_password,
|
|
||||||
)
|
|
||||||
if ctrl.connect():
|
|
||||||
self._ptz = ctrl
|
|
||||||
else:
|
|
||||||
self._ptz = None
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# 对外暴露的方法:供 Uni-Lab-OS 调用
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
def start(self, config: Optional[Dict[str, Any]] = None):
|
|
||||||
"""
|
|
||||||
启动 Camera 连接 & 消息循环,并在启动时就开启 FFmpeg 推流,
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self._running:
|
|
||||||
return {"status": "already_running", "host_id": self.host_id}
|
|
||||||
|
|
||||||
# 应用 config 覆盖(如果有)
|
|
||||||
if config:
|
|
||||||
self.camera_rtsp_url = config.get("camera_rtsp_url", self.camera_rtsp_url)
|
|
||||||
cfg_host_id = config.get("host_id")
|
|
||||||
if cfg_host_id:
|
|
||||||
self.host_id = cfg_host_id
|
|
||||||
|
|
||||||
signal_backend_url = config.get("signal_backend_url")
|
|
||||||
if signal_backend_url:
|
|
||||||
signal_backend_url = signal_backend_url.rstrip("/")
|
|
||||||
if not signal_backend_url.endswith("/host"):
|
|
||||||
signal_backend_url = signal_backend_url + "/host"
|
|
||||||
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
|
|
||||||
|
|
||||||
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
|
|
||||||
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
|
|
||||||
self.webrtc_stream_url = config.get(
|
|
||||||
"webrtc_stream_url", self.webrtc_stream_url
|
|
||||||
)
|
|
||||||
|
|
||||||
# PTZ 相关配置也允许通过 config 注入
|
|
||||||
self.ptz_host = config.get("ptz_host", self.ptz_host)
|
|
||||||
self.ptz_port = int(config.get("ptz_port", self.ptz_port))
|
|
||||||
self.ptz_user = config.get("ptz_user", self.ptz_user)
|
|
||||||
self.ptz_password = config.get("ptz_password", self.ptz_password)
|
|
||||||
self._init_ptz_if_possible()
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
|
|
||||||
# === start 时启动 FFmpeg 推流 ===
|
|
||||||
self._start_ffmpeg()
|
|
||||||
|
|
||||||
# 创建新的事件循环和线程(用于 WebSocket 信令)
|
|
||||||
self._loop = asyncio.new_event_loop()
|
|
||||||
|
|
||||||
def loop_runner(loop: asyncio.AbstractEventLoop):
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
try:
|
|
||||||
loop.run_forever()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
self._loop_thread = threading.Thread(
|
|
||||||
target=loop_runner, args=(self._loop,), daemon=True
|
|
||||||
)
|
|
||||||
self._loop_thread.start()
|
|
||||||
|
|
||||||
self._loop_task = asyncio.run_coroutine_threadsafe(
|
|
||||||
self._run_main_loop(), self._loop
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "started",
|
|
||||||
"host_id": self.host_id,
|
|
||||||
"signal_backend_url": self.signal_backend_url,
|
|
||||||
"rtmp_url": self.rtmp_url,
|
|
||||||
"webrtc_api": self.webrtc_api,
|
|
||||||
"webrtc_stream_url": self.webrtc_stream_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
def stop(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
停止推流 & 断开 WebSocket,并关闭事件循环线程。
|
|
||||||
"""
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
self._stop_ffmpeg()
|
|
||||||
|
|
||||||
if self._ws and self._loop is not None:
|
|
||||||
async def close_ws():
|
|
||||||
try:
|
|
||||||
await self._ws.close()
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error when closing WebSocket: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
|
|
||||||
|
|
||||||
if self._loop_task is not None:
|
|
||||||
if not self._loop_task.done():
|
|
||||||
self._loop_task.cancel()
|
|
||||||
try:
|
|
||||||
self._loop_task.result()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] main loop task error in stop(): {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self._loop_task = None
|
|
||||||
|
|
||||||
if self._loop is not None:
|
|
||||||
try:
|
|
||||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error when stopping event loop: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
if self._loop_thread is not None:
|
|
||||||
try:
|
|
||||||
self._loop_thread.join(timeout=5)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error when joining loop thread: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self._loop_thread = None
|
|
||||||
|
|
||||||
self._ws = None
|
|
||||||
self._loop = None
|
|
||||||
|
|
||||||
return {"status": "stopped", "host_id": self.host_id}
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
查询当前状态,方便在 Uni-Lab-OS 中做监控。
|
|
||||||
"""
|
|
||||||
ws_closed = None
|
|
||||||
if self._ws is not None:
|
|
||||||
ws_closed = getattr(self._ws, "closed", None)
|
|
||||||
|
|
||||||
if ws_closed is None:
|
|
||||||
websocket_connected = self._ws is not None
|
|
||||||
else:
|
|
||||||
websocket_connected = (self._ws is not None) and (not ws_closed)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"host_id": self.host_id,
|
|
||||||
"running": self._running,
|
|
||||||
"websocket_connected": websocket_connected,
|
|
||||||
"ffmpeg_running": bool(
|
|
||||||
self._ffmpeg_process and self._ffmpeg_process.poll() is None
|
|
||||||
),
|
|
||||||
"signal_backend_url": self.signal_backend_url,
|
|
||||||
"rtmp_url": self.rtmp_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# 内部实现逻辑:WebSocket 循环 / FFmpeg / WebRTC Offer 处理
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run_main_loop(self):
|
|
||||||
try:
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
async with websockets.connect(self.signal_backend_url) as ws:
|
|
||||||
self._ws = ws
|
|
||||||
await self._recv_loop()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
if self._running:
|
|
||||||
print(
|
|
||||||
f"[CameraController] WebSocket connection error: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _recv_loop(self):
|
|
||||||
assert self._ws is not None
|
|
||||||
ws = self._ws
|
|
||||||
|
|
||||||
async for message in ws:
|
|
||||||
try:
|
|
||||||
data = json.loads(message)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
print(
|
|
||||||
f"[CameraController] received non-JSON message: {message}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._handle_message(data)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error while handling message {data}: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_message(self, data: Dict[str, Any]):
|
|
||||||
"""
|
|
||||||
处理来自信令后端的消息:
|
|
||||||
- command: start_stream / stop_stream / ptz_xxx
|
|
||||||
- type: offer (WebRTC)
|
|
||||||
"""
|
|
||||||
cmd = data.get("command")
|
|
||||||
|
|
||||||
# ---------- 推流控制 ----------
|
|
||||||
if cmd == "start_stream":
|
|
||||||
try:
|
|
||||||
self._start_ffmpeg()
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error when starting FFmpeg on start_stream: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if cmd == "stop_stream":
|
|
||||||
try:
|
|
||||||
self._stop_ffmpeg()
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error when stopping FFmpeg on stop_stream: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# # ---------- PTZ 控制 ----------
|
|
||||||
# # 例如信令可以发:
|
|
||||||
# # {"command": "ptz_move", "direction": "down", "speed": 0.5, "duration": 0.5}
|
|
||||||
# if cmd == "ptz_move":
|
|
||||||
# if self._ptz is None:
|
|
||||||
# # 没有初始化 PTZ,静默忽略或打印一条
|
|
||||||
# print("[CameraController] PTZ not initialized.", file=sys.stderr)
|
|
||||||
# return
|
|
||||||
|
|
||||||
# direction = data.get("direction", "")
|
|
||||||
# speed = float(data.get("speed", 0.5))
|
|
||||||
# duration = float(data.get("duration", 0.5))
|
|
||||||
|
|
||||||
# try:
|
|
||||||
# if direction == "up":
|
|
||||||
# self._ptz.move_up(speed=speed, duration=duration)
|
|
||||||
# elif direction == "down":
|
|
||||||
# self._ptz.move_down(speed=speed, duration=duration)
|
|
||||||
# elif direction == "left":
|
|
||||||
# self._ptz.move_left(speed=speed, duration=duration)
|
|
||||||
# elif direction == "right":
|
|
||||||
# self._ptz.move_right(speed=speed, duration=duration)
|
|
||||||
# elif direction == "zoom_in":
|
|
||||||
# self._ptz.zoom_in(speed=speed, duration=duration)
|
|
||||||
# elif direction == "zoom_out":
|
|
||||||
# self._ptz.zoom_out(speed=speed, duration=duration)
|
|
||||||
# elif direction == "stop":
|
|
||||||
# self._ptz.stop()
|
|
||||||
# else:
|
|
||||||
# # 未知方向,忽略
|
|
||||||
# pass
|
|
||||||
# except Exception as e:
|
|
||||||
# print(
|
|
||||||
# f"[CameraController] error when handling PTZ move: {e}",
|
|
||||||
# file=sys.stderr,
|
|
||||||
# )
|
|
||||||
# return
|
|
||||||
|
|
||||||
# ---------- WebRTC Offer ----------
|
|
||||||
if data.get("type") == "offer":
|
|
||||||
offer_sdp = data.get("sdp", "")
|
|
||||||
camera_id = data.get("cameraId", "camera-01")
|
|
||||||
try:
|
|
||||||
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error when handling WebRTC offer: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self._ws:
|
|
||||||
answer_payload = {
|
|
||||||
"type": "answer",
|
|
||||||
"sdp": answer_sdp,
|
|
||||||
"cameraId": camera_id,
|
|
||||||
"hostId": self.host_id,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
await self._ws.send(json.dumps(answer_payload))
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error when sending WebRTC answer: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------ FFmpeg 相关 ------------------------
|
|
||||||
|
|
||||||
def _start_ffmpeg(self):
|
|
||||||
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-rtsp_transport", "tcp",
|
|
||||||
"-i", self.camera_rtsp_url,
|
|
||||||
|
|
||||||
"-c:v", "libx264",
|
|
||||||
"-preset", "ultrafast",
|
|
||||||
"-tune", "zerolatency",
|
|
||||||
"-profile:v", "baseline",
|
|
||||||
"-b:v", "1M",
|
|
||||||
"-maxrate", "1M",
|
|
||||||
"-bufsize", "2M",
|
|
||||||
"-g", "10",
|
|
||||||
"-keyint_min", "10",
|
|
||||||
"-sc_threshold", "0",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-x264-params", "bframes=0",
|
|
||||||
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-ar", "44100",
|
|
||||||
"-ac", "1",
|
|
||||||
"-b:a", "64k",
|
|
||||||
|
|
||||||
"-f", "flv",
|
|
||||||
self.rtmp_url,
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._ffmpeg_process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
shell=False,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
|
|
||||||
self._ffmpeg_process = None
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _stop_ffmpeg(self):
|
|
||||||
proc = self._ffmpeg_process
|
|
||||||
|
|
||||||
if proc and proc.poll() is None:
|
|
||||||
try:
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
try:
|
|
||||||
proc.kill()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=2)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
print(
|
|
||||||
f"[CameraController] FFmpeg process did not exit even after kill (pid={proc.pid})",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] failed to kill FFmpeg process: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] error when stopping FFmpeg: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._ffmpeg_process = None
|
|
||||||
|
|
||||||
# ------------------------ WebRTC Offer 相关 ------------------------
|
|
||||||
|
|
||||||
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
|
|
||||||
payload = {
|
|
||||||
"api": self.webrtc_api,
|
|
||||||
"streamurl": self.webrtc_stream_url,
|
|
||||||
"sdp": offer_sdp,
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
def _do_request():
|
|
||||||
return requests.post(
|
|
||||||
self.webrtc_api,
|
|
||||||
json=payload,
|
|
||||||
headers=headers,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
resp = await loop.run_in_executor(None, _do_request)
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] failed to send offer to media server: {e}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp.raise_for_status()
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] media server HTTP error: {e}, "
|
|
||||||
f"status={resp.status_code}, body={resp.text[:200]}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = resp.json()
|
|
||||||
except Exception as e:
|
|
||||||
print(
|
|
||||||
f"[CameraController] failed to parse media server JSON: {e}, "
|
|
||||||
f"raw={resp.text[:200]}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
answer_sdp = data.get("sdp", "")
|
|
||||||
if not answer_sdp:
|
|
||||||
msg = f"empty SDP from media server: {data}"
|
|
||||||
print(f"[CameraController] {msg}", file=sys.stderr)
|
|
||||||
raise RuntimeError(msg)
|
|
||||||
|
|
||||||
return answer_sdp
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import websockets
|
|
||||||
|
|
||||||
|
|
||||||
class CameraController:
|
|
||||||
"""
|
|
||||||
Uni-Lab-OS 摄像头驱动(Linux USB 摄像头版,无 PTZ)
|
|
||||||
|
|
||||||
- WebSocket 信令:signal_backend_url 连接到后端
|
|
||||||
例如: wss://sciol.ac.cn/api/realtime/signal/host/<host_id>
|
|
||||||
- 媒体服务器:RTMP 推流到 rtmp_url;WebRTC offer 转发到 SRS 的 webrtc_api
|
|
||||||
- 视频源:本地 USB 摄像头(V4L2,默认 /dev/video0)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
host_id: str = "demo-host",
|
|
||||||
signal_backend_url: str = "wss://sciol.ac.cn/api/realtime/signal/host",
|
|
||||||
rtmp_url: str = "rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
|
||||||
webrtc_api: str = "https://srs.sciol.ac.cn/rtc/v1/play/",
|
|
||||||
webrtc_stream_url: str = "webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
|
||||||
video_device: str = "/dev/video0",
|
|
||||||
width: int = 1280,
|
|
||||||
height: int = 720,
|
|
||||||
fps: int = 30,
|
|
||||||
video_bitrate: str = "1500k",
|
|
||||||
audio_device: Optional[str] = None, # 比如 "hw:1,0",没有音频就保持 None
|
|
||||||
audio_bitrate: str = "64k",
|
|
||||||
):
|
|
||||||
self.host_id = host_id
|
|
||||||
|
|
||||||
# 拼接最终 WebSocket URL:.../host/<host_id>
|
|
||||||
signal_backend_url = signal_backend_url.rstrip("/")
|
|
||||||
if not signal_backend_url.endswith("/host"):
|
|
||||||
signal_backend_url = signal_backend_url + "/host"
|
|
||||||
self.signal_backend_url = f"{signal_backend_url}/{host_id}"
|
|
||||||
|
|
||||||
# 媒体服务器配置
|
|
||||||
self.rtmp_url = rtmp_url
|
|
||||||
self.webrtc_api = webrtc_api
|
|
||||||
self.webrtc_stream_url = webrtc_stream_url
|
|
||||||
|
|
||||||
# 本地采集配置
|
|
||||||
self.video_device = video_device
|
|
||||||
self.width = int(width)
|
|
||||||
self.height = int(height)
|
|
||||||
self.fps = int(fps)
|
|
||||||
self.video_bitrate = video_bitrate
|
|
||||||
self.audio_device = audio_device
|
|
||||||
self.audio_bitrate = audio_bitrate
|
|
||||||
|
|
||||||
# 运行时状态
|
|
||||||
self._ws: Optional[object] = None
|
|
||||||
self._ffmpeg_process: Optional[subprocess.Popen] = None
|
|
||||||
self._running = False
|
|
||||||
self._loop_task: Optional[asyncio.Future] = None
|
|
||||||
|
|
||||||
# 事件循环 & 线程
|
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
||||||
self._loop_thread: Optional[threading.Thread] = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.start()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] __init__ auto start failed: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# 对外方法
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
def start(self, config: Optional[Dict[str, Any]] = None):
|
|
||||||
if self._running:
|
|
||||||
return {"status": "already_running", "host_id": self.host_id}
|
|
||||||
|
|
||||||
# 应用 config 覆盖(如果有)
|
|
||||||
if config:
|
|
||||||
cfg_host_id = config.get("host_id")
|
|
||||||
if cfg_host_id:
|
|
||||||
self.host_id = cfg_host_id
|
|
||||||
|
|
||||||
signal_backend_url = config.get("signal_backend_url")
|
|
||||||
if signal_backend_url:
|
|
||||||
signal_backend_url = signal_backend_url.rstrip("/")
|
|
||||||
if not signal_backend_url.endswith("/host"):
|
|
||||||
signal_backend_url = signal_backend_url + "/host"
|
|
||||||
self.signal_backend_url = f"{signal_backend_url}/{self.host_id}"
|
|
||||||
|
|
||||||
self.rtmp_url = config.get("rtmp_url", self.rtmp_url)
|
|
||||||
self.webrtc_api = config.get("webrtc_api", self.webrtc_api)
|
|
||||||
self.webrtc_stream_url = config.get("webrtc_stream_url", self.webrtc_stream_url)
|
|
||||||
|
|
||||||
self.video_device = config.get("video_device", self.video_device)
|
|
||||||
self.width = int(config.get("width", self.width))
|
|
||||||
self.height = int(config.get("height", self.height))
|
|
||||||
self.fps = int(config.get("fps", self.fps))
|
|
||||||
self.video_bitrate = config.get("video_bitrate", self.video_bitrate)
|
|
||||||
self.audio_device = config.get("audio_device", self.audio_device)
|
|
||||||
self.audio_bitrate = config.get("audio_bitrate", self.audio_bitrate)
|
|
||||||
|
|
||||||
self._running = True
|
|
||||||
|
|
||||||
print("[CameraController] start(): starting FFmpeg streaming...", file=sys.stderr)
|
|
||||||
self._start_ffmpeg()
|
|
||||||
|
|
||||||
self._loop = asyncio.new_event_loop()
|
|
||||||
|
|
||||||
def loop_runner(loop: asyncio.AbstractEventLoop):
|
|
||||||
asyncio.set_event_loop(loop)
|
|
||||||
try:
|
|
||||||
loop.run_forever()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] event loop error: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
self._loop_thread = threading.Thread(target=loop_runner, args=(self._loop,), daemon=True)
|
|
||||||
self._loop_thread.start()
|
|
||||||
|
|
||||||
self._loop_task = asyncio.run_coroutine_threadsafe(self._run_main_loop(), self._loop)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "started",
|
|
||||||
"host_id": self.host_id,
|
|
||||||
"signal_backend_url": self.signal_backend_url,
|
|
||||||
"rtmp_url": self.rtmp_url,
|
|
||||||
"webrtc_api": self.webrtc_api,
|
|
||||||
"webrtc_stream_url": self.webrtc_stream_url,
|
|
||||||
"video_device": self.video_device,
|
|
||||||
"width": self.width,
|
|
||||||
"height": self.height,
|
|
||||||
"fps": self.fps,
|
|
||||||
"video_bitrate": self.video_bitrate,
|
|
||||||
"audio_device": self.audio_device,
|
|
||||||
}
|
|
||||||
|
|
||||||
def stop(self) -> Dict[str, Any]:
|
|
||||||
self._running = False
|
|
||||||
|
|
||||||
# 先取消主任务(让 ws connect/sleep 尽快退出)
|
|
||||||
if self._loop_task is not None and not self._loop_task.done():
|
|
||||||
self._loop_task.cancel()
|
|
||||||
|
|
||||||
# 停止推流
|
|
||||||
self._stop_ffmpeg()
|
|
||||||
|
|
||||||
# 关闭 WebSocket(在 loop 中执行)
|
|
||||||
if self._ws and self._loop is not None:
|
|
||||||
|
|
||||||
async def close_ws():
|
|
||||||
try:
|
|
||||||
await self._ws.close()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] error closing WebSocket: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
try:
|
|
||||||
asyncio.run_coroutine_threadsafe(close_ws(), self._loop)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 停止事件循环
|
|
||||||
if self._loop is not None:
|
|
||||||
try:
|
|
||||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] error stopping loop: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
# 等待线程退出
|
|
||||||
if self._loop_thread is not None:
|
|
||||||
try:
|
|
||||||
self._loop_thread.join(timeout=5)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] error joining loop thread: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
self._ws = None
|
|
||||||
self._loop_task = None
|
|
||||||
self._loop = None
|
|
||||||
self._loop_thread = None
|
|
||||||
|
|
||||||
return {"status": "stopped", "host_id": self.host_id}
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
|
||||||
ws_closed = None
|
|
||||||
if self._ws is not None:
|
|
||||||
ws_closed = getattr(self._ws, "closed", None)
|
|
||||||
|
|
||||||
if ws_closed is None:
|
|
||||||
websocket_connected = self._ws is not None
|
|
||||||
else:
|
|
||||||
websocket_connected = (self._ws is not None) and (not ws_closed)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"host_id": self.host_id,
|
|
||||||
"running": self._running,
|
|
||||||
"websocket_connected": websocket_connected,
|
|
||||||
"ffmpeg_running": bool(self._ffmpeg_process and self._ffmpeg_process.poll() is None),
|
|
||||||
"signal_backend_url": self.signal_backend_url,
|
|
||||||
"rtmp_url": self.rtmp_url,
|
|
||||||
"video_device": self.video_device,
|
|
||||||
"width": self.width,
|
|
||||||
"height": self.height,
|
|
||||||
"fps": self.fps,
|
|
||||||
"video_bitrate": self.video_bitrate,
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# WebSocket / 信令
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _run_main_loop(self):
|
|
||||||
print("[CameraController] main loop started", file=sys.stderr)
|
|
||||||
try:
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
async with websockets.connect(self.signal_backend_url) as ws:
|
|
||||||
self._ws = ws
|
|
||||||
print(f"[CameraController] WebSocket connected: {self.signal_backend_url}", file=sys.stderr)
|
|
||||||
await self._recv_loop()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
if self._running:
|
|
||||||
print(f"[CameraController] WebSocket connection error: {e}", file=sys.stderr)
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
print("[CameraController] main loop exited", file=sys.stderr)
|
|
||||||
|
|
||||||
async def _recv_loop(self):
|
|
||||||
assert self._ws is not None
|
|
||||||
ws = self._ws
|
|
||||||
|
|
||||||
async for message in ws:
|
|
||||||
try:
|
|
||||||
data = json.loads(message)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
print(f"[CameraController] non-JSON message: {message}", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._handle_message(data)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] error handling message {data}: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
async def _handle_message(self, data: Dict[str, Any]):
|
|
||||||
cmd = data.get("command")
|
|
||||||
|
|
||||||
if cmd == "start_stream":
|
|
||||||
self._start_ffmpeg()
|
|
||||||
return
|
|
||||||
|
|
||||||
if cmd == "stop_stream":
|
|
||||||
self._stop_ffmpeg()
|
|
||||||
return
|
|
||||||
|
|
||||||
if data.get("type") == "offer":
|
|
||||||
offer_sdp = data.get("sdp", "")
|
|
||||||
camera_id = data.get("cameraId", "camera-01")
|
|
||||||
|
|
||||||
answer_sdp = await self._handle_webrtc_offer(offer_sdp)
|
|
||||||
|
|
||||||
if self._ws:
|
|
||||||
answer_payload = {
|
|
||||||
"type": "answer",
|
|
||||||
"sdp": answer_sdp,
|
|
||||||
"cameraId": camera_id,
|
|
||||||
"hostId": self.host_id,
|
|
||||||
}
|
|
||||||
await self._ws.send(json.dumps(answer_payload))
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# FFmpeg 推流(V4L2 USB 摄像头)
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _start_ffmpeg(self):
|
|
||||||
if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 兼容性优先:不强制输入像素格式;失败再通过外部调整 width/height/fps
|
|
||||||
video_size = f"{self.width}x{self.height}"
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"warning",
|
|
||||||
|
|
||||||
# video input
|
|
||||||
"-f", "v4l2",
|
|
||||||
"-framerate", str(self.fps),
|
|
||||||
"-video_size", video_size,
|
|
||||||
"-i", self.video_device,
|
|
||||||
]
|
|
||||||
|
|
||||||
# optional audio input
|
|
||||||
if self.audio_device:
|
|
||||||
cmd += [
|
|
||||||
"-f", "alsa",
|
|
||||||
"-i", self.audio_device,
|
|
||||||
"-c:a", "aac",
|
|
||||||
"-b:a", self.audio_bitrate,
|
|
||||||
"-ar", "44100",
|
|
||||||
"-ac", "1",
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
cmd += ["-an"]
|
|
||||||
|
|
||||||
# video encode + rtmp out
|
|
||||||
cmd += [
|
|
||||||
"-c:v", "libx264",
|
|
||||||
"-preset", "ultrafast",
|
|
||||||
"-tune", "zerolatency",
|
|
||||||
"-profile:v", "baseline",
|
|
||||||
"-pix_fmt", "yuv420p",
|
|
||||||
"-b:v", self.video_bitrate,
|
|
||||||
"-maxrate", self.video_bitrate,
|
|
||||||
"-bufsize", "2M",
|
|
||||||
"-g", str(max(self.fps, 10)),
|
|
||||||
"-keyint_min", str(max(self.fps, 10)),
|
|
||||||
"-sc_threshold", "0",
|
|
||||||
"-x264-params", "bframes=0",
|
|
||||||
|
|
||||||
"-f", "flv",
|
|
||||||
self.rtmp_url,
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"[CameraController] starting FFmpeg: {' '.join(cmd)}", file=sys.stderr)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 不再丢弃日志,至少能看到 ffmpeg 报错(调试很关键)
|
|
||||||
self._ffmpeg_process = subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=sys.stderr,
|
|
||||||
shell=False,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self._ffmpeg_process = None
|
|
||||||
print(f"[CameraController] failed to start FFmpeg: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
def _stop_ffmpeg(self):
|
|
||||||
proc = self._ffmpeg_process
|
|
||||||
if proc and proc.poll() is None:
|
|
||||||
try:
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[CameraController] error stopping FFmpeg: {e}", file=sys.stderr)
|
|
||||||
self._ffmpeg_process = None
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# WebRTC offer -> SRS
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def _handle_webrtc_offer(self, offer_sdp: str) -> str:
|
|
||||||
payload = {
|
|
||||||
"api": self.webrtc_api,
|
|
||||||
"streamurl": self.webrtc_stream_url,
|
|
||||||
"sdp": offer_sdp,
|
|
||||||
}
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
|
|
||||||
def _do_post():
|
|
||||||
return requests.post(self.webrtc_api, json=payload, headers=headers, timeout=10)
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
resp = await loop.run_in_executor(None, _do_post)
|
|
||||||
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
answer_sdp = data.get("sdp", "")
|
|
||||||
if not answer_sdp:
|
|
||||||
raise RuntimeError(f"empty SDP from media server: {data}")
|
|
||||||
return answer_sdp
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# 直接运行用于手动测试
|
|
||||||
c = CameraController(
|
|
||||||
host_id="demo-host",
|
|
||||||
video_device="/dev/video0",
|
|
||||||
width=1280,
|
|
||||||
height=720,
|
|
||||||
fps=30,
|
|
||||||
video_bitrate="1500k",
|
|
||||||
audio_device=None,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
asyncio.sleep(1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
c.stop()
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import time
|
|
||||||
import json
|
|
||||||
|
|
||||||
from cameraUSB import CameraController
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# 按你的实际情况改
|
|
||||||
cfg = dict(
|
|
||||||
host_id="demo-host",
|
|
||||||
signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host",
|
|
||||||
rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
|
||||||
webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/",
|
|
||||||
webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
|
||||||
video_device="/dev/video7",
|
|
||||||
width=1280,
|
|
||||||
height=720,
|
|
||||||
fps=30,
|
|
||||||
video_bitrate="1500k",
|
|
||||||
audio_device=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
c = CameraController(**cfg)
|
|
||||||
|
|
||||||
# 可选:如果你不想依赖 __init__ 自动 start,可以这样显式调用:
|
|
||||||
# c = CameraController(host_id=cfg["host_id"])
|
|
||||||
# c.start(cfg)
|
|
||||||
|
|
||||||
run_seconds = 30 # 测试运行时长
|
|
||||||
t0 = time.time()
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
st = c.get_status()
|
|
||||||
print(json.dumps(st, ensure_ascii=False, indent=2))
|
|
||||||
|
|
||||||
if time.time() - t0 >= run_seconds:
|
|
||||||
break
|
|
||||||
|
|
||||||
time.sleep(2)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("Interrupted, stopping...")
|
|
||||||
finally:
|
|
||||||
print("Stopping controller...")
|
|
||||||
c.stop()
|
|
||||||
print("Done.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import cv2
|
|
||||||
|
|
||||||
# 推荐把 @ 进行 URL 编码:@ -> %40
|
|
||||||
RTSP_URL = "rtsp://admin:admin123@192.168.31.164:554/stream1"
|
|
||||||
OUTPUT_IMAGE = "rtsp_test_frame.jpg"
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print(f"尝试连接 RTSP 流: {RTSP_URL}")
|
|
||||||
cap = cv2.VideoCapture(RTSP_URL)
|
|
||||||
|
|
||||||
if not cap.isOpened():
|
|
||||||
print("错误:无法打开 RTSP 流,请检查:")
|
|
||||||
print(" 1. IP/端口是否正确")
|
|
||||||
print(" 2. 账号密码(尤其是 @ 是否已转成 %40)是否正确")
|
|
||||||
print(" 3. 摄像头是否允许当前主机访问(同一网段、防火墙等)")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("连接成功,开始读取一帧...")
|
|
||||||
ret, frame = cap.read()
|
|
||||||
|
|
||||||
if not ret or frame is None:
|
|
||||||
print("错误:已连接但未能读取到帧数据(可能是码流未开启或网络抖动)")
|
|
||||||
cap.release()
|
|
||||||
return
|
|
||||||
|
|
||||||
# 保存当前帧
|
|
||||||
success = cv2.imwrite(OUTPUT_IMAGE, frame)
|
|
||||||
cap.release()
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print(f"成功截取一帧并保存为: {OUTPUT_IMAGE}")
|
|
||||||
else:
|
|
||||||
print("错误:写入图片失败,请检查磁盘权限/路径")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# run_camera_push.py
|
|
||||||
import time
|
|
||||||
from cameraDriver import CameraController # 这里根据你的文件名调整
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
controller = CameraController(
|
|
||||||
host_id="demo-host",
|
|
||||||
signal_backend_url="wss://sciol.ac.cn/api/realtime/signal/host",
|
|
||||||
rtmp_url="rtmp://srs.sciol.ac.cn:4499/live/camera-01",
|
|
||||||
webrtc_api="https://srs.sciol.ac.cn/rtc/v1/play/",
|
|
||||||
webrtc_stream_url="webrtc://srs.sciol.ac.cn:4500/live/camera-01",
|
|
||||||
camera_rtsp_url="rtsp://admin:admin123@192.168.31.164:554/stream1",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
status = controller.get_status()
|
|
||||||
print(status)
|
|
||||||
time.sleep(5)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
controller.stop()
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
|
||||||
使用 CameraController 来测试 PTZ:
|
|
||||||
让摄像头按顺序向下、向上、向左、向右运动几次。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# 根据你的工程结构修改导入路径:
|
|
||||||
# 假设 CameraController 定义在 cameraController.py 里
|
|
||||||
from cameraDriver import CameraController
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# === 根据你的实际情况填 IP、端口、账号密码 ===
|
|
||||||
ptz_host = "192.168.31.164"
|
|
||||||
ptz_port = 2020 # 注意要和你单独测试 PTZController 时保持一致
|
|
||||||
ptz_user = "admin"
|
|
||||||
ptz_password = "admin123"
|
|
||||||
|
|
||||||
# 1. 创建 CameraController 实例
|
|
||||||
cam = CameraController(
|
|
||||||
# 其他摄像机相关参数按你类的 __init__ 来补充
|
|
||||||
ptz_host=ptz_host,
|
|
||||||
ptz_port=ptz_port,
|
|
||||||
ptz_user=ptz_user,
|
|
||||||
ptz_password=ptz_password,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 启动 / 初始化(如果你的 CameraController 有 start(config) 之类的接口)
|
|
||||||
# 这里给一个最小的 config,重点是 PTZ 相关字段
|
|
||||||
config = {
|
|
||||||
"ptz_host": ptz_host,
|
|
||||||
"ptz_port": ptz_port,
|
|
||||||
"ptz_user": ptz_user,
|
|
||||||
"ptz_password": ptz_password,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
cam.start(config)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[TEST] CameraController start() 失败: {e}", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 这里可以判断一下内部 _ptz 是否初始化成功(如果你对 CameraController 做了封装)
|
|
||||||
if getattr(cam, "_ptz", None) is None:
|
|
||||||
print("[TEST] CameraController 内部 PTZ 未初始化成功,请检查 ptz_host/port/user/password 配置。", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
# 3. 依次调用 CameraController 的 PTZ 方法
|
|
||||||
# 这里假设你在 CameraController 中提供了这几个对外方法:
|
|
||||||
# ptz_move_down / ptz_move_up / ptz_move_left / ptz_move_right
|
|
||||||
# 如果你命名不一样,把下面调用名改成你的即可。
|
|
||||||
|
|
||||||
print("向下移动(通过 CameraController)...")
|
|
||||||
cam.ptz_move_down(speed=0.5, duration=1.0)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("向上移动(通过 CameraController)...")
|
|
||||||
cam.ptz_move_up(speed=0.5, duration=1.0)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("向左移动(通过 CameraController)...")
|
|
||||||
cam.ptz_move_left(speed=0.5, duration=1.0)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("向右移动(通过 CameraController)...")
|
|
||||||
cam.ptz_move_right(speed=0.5, duration=1.0)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("测试结束。")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
"""
|
|
||||||
测试 cameraDriver.py中的 PTZController 类,让摄像头按顺序运动几次
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from cameraDriver import PTZController
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# 根据你的实际情况填 IP、端口、账号密码
|
|
||||||
host = "192.168.31.164"
|
|
||||||
port = 80
|
|
||||||
user = "admin"
|
|
||||||
password = "admin123"
|
|
||||||
|
|
||||||
ptz = PTZController(host=host, port=port, user=user, password=password)
|
|
||||||
|
|
||||||
# 1. 连接摄像头
|
|
||||||
if not ptz.connect():
|
|
||||||
print("连接 PTZ 失败,检查 IP/用户名/密码/端口。")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. 依次测试几个动作
|
|
||||||
# 每个动作之间 sleep 一下方便观察
|
|
||||||
|
|
||||||
print("向下移动...")
|
|
||||||
ptz.move_down(speed=0.5, duration=1.0)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("向上移动...")
|
|
||||||
ptz.move_up(speed=0.5, duration=1.0)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("向左移动...")
|
|
||||||
ptz.move_left(speed=0.5, duration=1.0)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("向右移动...")
|
|
||||||
ptz.move_right(speed=0.5, duration=1.0)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
print("测试结束。")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
307
unilabos/devices/laiyu_liquid/__init__.py
Normal file
307
unilabos/devices/laiyu_liquid/__init__.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
LaiYu_Liquid 液体处理工作站集成模块
|
||||||
|
|
||||||
|
该模块提供了 LaiYu_Liquid 工作站与 UniLabOS 的完整集成,包括:
|
||||||
|
- 硬件后端和抽象接口
|
||||||
|
- 资源定义和管理
|
||||||
|
- 协议执行和液体传输
|
||||||
|
- 工作台配置和布局
|
||||||
|
|
||||||
|
主要组件:
|
||||||
|
- LaiYuLiquidBackend: 硬件后端实现
|
||||||
|
- LaiYuLiquid: 液体处理器抽象接口
|
||||||
|
- 各种资源类:枪头架、板、容器等
|
||||||
|
- 便捷创建函数和配置管理
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
from unilabos.devices.laiyu_liquid import (
|
||||||
|
LaiYuLiquid,
|
||||||
|
LaiYuLiquidBackend,
|
||||||
|
create_standard_deck,
|
||||||
|
create_tip_rack_1000ul
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建后端和液体处理器
|
||||||
|
backend = LaiYuLiquidBackend()
|
||||||
|
lh = LaiYuLiquid(backend=backend)
|
||||||
|
|
||||||
|
# 创建工作台
|
||||||
|
deck = create_standard_deck()
|
||||||
|
lh.deck = deck
|
||||||
|
|
||||||
|
# 设置和运行
|
||||||
|
await lh.setup()
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 版本信息
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "LaiYu_Liquid Integration Team"
|
||||||
|
__description__ = "LaiYu_Liquid 液体处理工作站 UniLabOS 集成模块"
|
||||||
|
|
||||||
|
# 驱动程序导入
|
||||||
|
from .drivers import (
|
||||||
|
XYZStepperController,
|
||||||
|
SOPAPipette,
|
||||||
|
MotorAxis,
|
||||||
|
MotorStatus,
|
||||||
|
SOPAConfig,
|
||||||
|
SOPAStatusCode,
|
||||||
|
StepperMotorDriver
|
||||||
|
)
|
||||||
|
|
||||||
|
# 控制器导入
|
||||||
|
from .controllers import (
|
||||||
|
XYZController,
|
||||||
|
PipetteController,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 后端导入
|
||||||
|
from .backend.rviz_backend import (
|
||||||
|
LiquidHandlerRvizBackend,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 资源类和创建函数导入
|
||||||
|
from .core.laiyu_liquid_res import (
|
||||||
|
LaiYuLiquidDeck,
|
||||||
|
LaiYuLiquidContainer,
|
||||||
|
LaiYuLiquidTipRack
|
||||||
|
)
|
||||||
|
|
||||||
|
# 主设备类和配置
|
||||||
|
from .core.laiyu_liquid_main import (
|
||||||
|
LaiYuLiquid,
|
||||||
|
LaiYuLiquidConfig,
|
||||||
|
LaiYuLiquidDeck,
|
||||||
|
LaiYuLiquidContainer,
|
||||||
|
LaiYuLiquidTipRack,
|
||||||
|
create_quick_setup
|
||||||
|
)
|
||||||
|
|
||||||
|
# 后端创建函数导入
|
||||||
|
from .backend import (
|
||||||
|
LaiYuLiquidBackend,
|
||||||
|
create_laiyu_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 导出所有公共接口
|
||||||
|
__all__ = [
|
||||||
|
# 版本信息
|
||||||
|
"__version__",
|
||||||
|
"__author__",
|
||||||
|
"__description__",
|
||||||
|
|
||||||
|
# 驱动程序
|
||||||
|
"SOPAPipette",
|
||||||
|
"SOPAConfig",
|
||||||
|
"StepperMotorDriver",
|
||||||
|
"XYZStepperController",
|
||||||
|
|
||||||
|
# 控制器
|
||||||
|
"PipetteController",
|
||||||
|
"XYZController",
|
||||||
|
|
||||||
|
# 后端
|
||||||
|
"LiquidHandlerRvizBackend",
|
||||||
|
|
||||||
|
# 资源创建函数
|
||||||
|
"create_tip_rack_1000ul",
|
||||||
|
"create_tip_rack_200ul",
|
||||||
|
"create_96_well_plate",
|
||||||
|
"create_deep_well_plate",
|
||||||
|
"create_8_tube_rack",
|
||||||
|
"create_standard_deck",
|
||||||
|
"create_waste_container",
|
||||||
|
"create_wash_container",
|
||||||
|
"create_reagent_container",
|
||||||
|
"load_deck_config",
|
||||||
|
|
||||||
|
# 后端创建函数
|
||||||
|
"create_laiyu_backend",
|
||||||
|
|
||||||
|
# 主要类
|
||||||
|
"LaiYuLiquid",
|
||||||
|
"LaiYuLiquidConfig",
|
||||||
|
"LaiYuLiquidBackend",
|
||||||
|
"LaiYuLiquidDeck",
|
||||||
|
|
||||||
|
# 工具函数
|
||||||
|
"get_version",
|
||||||
|
"get_supported_resources",
|
||||||
|
"create_quick_setup",
|
||||||
|
"validate_installation",
|
||||||
|
"print_module_info",
|
||||||
|
"setup_logging",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 别名定义,为了向后兼容
|
||||||
|
LaiYuLiquidDevice = LaiYuLiquid # 主设备类别名
|
||||||
|
LaiYuLiquidController = XYZController # 控制器别名
|
||||||
|
LaiYuLiquidDriver = XYZStepperController # 驱动器别名
|
||||||
|
|
||||||
|
# 模块级别的便捷函数
|
||||||
|
|
||||||
|
def get_version() -> str:
|
||||||
|
"""
|
||||||
|
获取模块版本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 版本号
|
||||||
|
"""
|
||||||
|
return __version__
|
||||||
|
|
||||||
|
|
||||||
|
def get_supported_resources() -> dict:
|
||||||
|
"""
|
||||||
|
获取支持的资源类型
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: 支持的资源类型字典
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"tip_racks": {
|
||||||
|
"LaiYuLiquidTipRack": LaiYuLiquidTipRack,
|
||||||
|
},
|
||||||
|
"containers": {
|
||||||
|
"LaiYuLiquidContainer": LaiYuLiquidContainer,
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"LaiYuLiquidDeck": LaiYuLiquidDeck,
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"LaiYuLiquid": LaiYuLiquid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_quick_setup() -> tuple:
|
||||||
|
"""
|
||||||
|
快速创建基本设置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (backend, controllers, resources) 的元组
|
||||||
|
"""
|
||||||
|
# 创建后端
|
||||||
|
backend = LiquidHandlerRvizBackend()
|
||||||
|
|
||||||
|
# 创建控制器(使用默认端口进行演示)
|
||||||
|
pipette_controller = PipetteController(port="/dev/ttyUSB0", address=4)
|
||||||
|
xyz_controller = XYZController(port="/dev/ttyUSB1", auto_connect=False)
|
||||||
|
|
||||||
|
# 创建测试资源
|
||||||
|
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||||
|
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||||
|
well_plate = create_96_well_plate("96_well_plate")
|
||||||
|
|
||||||
|
controllers = {
|
||||||
|
'pipette': pipette_controller,
|
||||||
|
'xyz': xyz_controller
|
||||||
|
}
|
||||||
|
|
||||||
|
resources = {
|
||||||
|
'tip_rack_1000': tip_rack_1000,
|
||||||
|
'tip_rack_200': tip_rack_200,
|
||||||
|
'well_plate': well_plate
|
||||||
|
}
|
||||||
|
|
||||||
|
return backend, controllers, resources
|
||||||
|
|
||||||
|
|
||||||
|
def validate_installation() -> bool:
|
||||||
|
"""
|
||||||
|
验证模块安装是否正确
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 安装是否正确
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 检查核心类是否可以导入
|
||||||
|
from .core.laiyu_liquid_main import LaiYuLiquid, LaiYuLiquidConfig
|
||||||
|
from .backend import LaiYuLiquidBackend
|
||||||
|
from .controllers import XYZController, PipetteController
|
||||||
|
from .drivers import XYZStepperController, SOPAPipette
|
||||||
|
|
||||||
|
# 尝试创建基本对象
|
||||||
|
config = LaiYuLiquidConfig()
|
||||||
|
backend = create_laiyu_backend("validation_test")
|
||||||
|
|
||||||
|
print("模块安装验证成功")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"模块安装验证失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def print_module_info():
|
||||||
|
"""打印模块信息"""
|
||||||
|
print(f"LaiYu_Liquid 集成模块")
|
||||||
|
print(f"版本: {__version__}")
|
||||||
|
print(f"作者: {__author__}")
|
||||||
|
print(f"描述: {__description__}")
|
||||||
|
print(f"")
|
||||||
|
print(f"支持的资源类型:")
|
||||||
|
|
||||||
|
resources = get_supported_resources()
|
||||||
|
for category, types in resources.items():
|
||||||
|
print(f" {category}:")
|
||||||
|
for type_name, type_class in types.items():
|
||||||
|
print(f" - {type_name}: {type_class.__name__}")
|
||||||
|
|
||||||
|
print(f"")
|
||||||
|
print(f"主要功能:")
|
||||||
|
print(f" - 硬件集成: LaiYuLiquidBackend")
|
||||||
|
print(f" - 抽象接口: LaiYuLiquid")
|
||||||
|
print(f" - 资源管理: 各种资源类和创建函数")
|
||||||
|
print(f" - 协议执行: transfer_liquid 和相关函数")
|
||||||
|
print(f" - 配置管理: deck.json 和加载函数")
|
||||||
|
|
||||||
|
|
||||||
|
# 模块初始化时的检查
|
||||||
|
def _check_dependencies():
|
||||||
|
"""检查依赖项"""
|
||||||
|
try:
|
||||||
|
import pylabrobot
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
return True
|
||||||
|
except ImportError as e:
|
||||||
|
import logging
|
||||||
|
logging.warning(f"缺少依赖项 {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# 执行依赖检查
|
||||||
|
_dependencies_ok = _check_dependencies()
|
||||||
|
|
||||||
|
if not _dependencies_ok:
|
||||||
|
import logging
|
||||||
|
logging.warning("某些依赖项缺失,模块功能可能受限")
|
||||||
|
|
||||||
|
|
||||||
|
# 模块级别的日志配置
|
||||||
|
import logging
|
||||||
|
|
||||||
|
def setup_logging(level: str = "INFO"):
|
||||||
|
"""
|
||||||
|
设置模块日志
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: 日志级别 (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger("LaiYu_Liquid")
|
||||||
|
logger.setLevel(getattr(logging, level.upper()))
|
||||||
|
|
||||||
|
if not logger.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
# 默认日志设置
|
||||||
|
_logger = setup_logging()
|
||||||
9
unilabos/devices/laiyu_liquid/backend/__init__.py
Normal file
9
unilabos/devices/laiyu_liquid/backend/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
LaiYu液体处理设备后端模块
|
||||||
|
|
||||||
|
提供设备后端接口和实现
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend
|
||||||
|
|
||||||
|
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend']
|
||||||
334
unilabos/devices/laiyu_liquid/backend/laiyu_backend.py
Normal file
334
unilabos/devices/laiyu_liquid/backend/laiyu_backend.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"""
|
||||||
|
LaiYu液体处理设备后端实现
|
||||||
|
|
||||||
|
提供设备的后端接口和控制逻辑
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
# 尝试导入PyLabRobot后端
|
||||||
|
try:
|
||||||
|
from pylabrobot.liquid_handling.backends import LiquidHandlerBackend
|
||||||
|
PYLABROBOT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PYLABROBOT_AVAILABLE = False
|
||||||
|
# 创建模拟后端基类
|
||||||
|
class LiquidHandlerBackend:
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
self.is_connected = False
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""连接设备"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""断开连接"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuLiquidBackend(LiquidHandlerBackend):
|
||||||
|
"""LaiYu液体处理设备后端"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "LaiYu_Liquid_Backend"):
|
||||||
|
"""
|
||||||
|
初始化LaiYu液体处理设备后端
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 后端名称
|
||||||
|
"""
|
||||||
|
if PYLABROBOT_AVAILABLE:
|
||||||
|
# PyLabRobot 的 LiquidHandlerBackend 不接受参数
|
||||||
|
super().__init__()
|
||||||
|
else:
|
||||||
|
# 模拟版本接受 name 参数
|
||||||
|
super().__init__(name)
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.is_connected = False
|
||||||
|
self.device_info = {
|
||||||
|
"name": "LaiYu液体处理设备",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"manufacturer": "LaiYu",
|
||||||
|
"model": "LaiYu_Liquid_Handler"
|
||||||
|
}
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""
|
||||||
|
连接到LaiYu液体处理设备
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 连接是否成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.logger.info("正在连接到LaiYu液体处理设备...")
|
||||||
|
# 这里应该实现实际的设备连接逻辑
|
||||||
|
# 目前返回模拟连接成功
|
||||||
|
self.is_connected = True
|
||||||
|
self.logger.info("成功连接到LaiYu液体处理设备")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"连接LaiYu液体处理设备失败: {e}")
|
||||||
|
self.is_connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self) -> bool:
|
||||||
|
"""
|
||||||
|
断开与LaiYu液体处理设备的连接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 断开连接是否成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.logger.info("正在断开与LaiYu液体处理设备的连接...")
|
||||||
|
# 这里应该实现实际的设备断开连接逻辑
|
||||||
|
self.is_connected = False
|
||||||
|
self.logger.info("成功断开与LaiYu液体处理设备的连接")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"断开LaiYu液体处理设备连接失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_device_connected(self) -> bool:
|
||||||
|
"""
|
||||||
|
检查设备是否已连接
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 设备是否已连接
|
||||||
|
"""
|
||||||
|
return self.is_connected
|
||||||
|
|
||||||
|
def get_device_info(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取设备信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 设备信息字典
|
||||||
|
"""
|
||||||
|
return self.device_info.copy()
|
||||||
|
|
||||||
|
def home_device(self) -> bool:
|
||||||
|
"""
|
||||||
|
设备归零操作
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 归零是否成功
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
self.logger.error("设备未连接,无法执行归零操作")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info("正在执行设备归零操作...")
|
||||||
|
# 这里应该实现实际的设备归零逻辑
|
||||||
|
self.logger.info("设备归零操作完成")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"设备归零操作失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def aspirate(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
吸液操作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volume: 吸液体积 (微升)
|
||||||
|
location: 吸液位置信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 吸液是否成功
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
self.logger.error("设备未连接,无法执行吸液操作")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(f"正在执行吸液操作: 体积={volume}μL, 位置={location}")
|
||||||
|
# 这里应该实现实际的吸液逻辑
|
||||||
|
self.logger.info("吸液操作完成")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"吸液操作失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def dispense(self, volume: float, location: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
排液操作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volume: 排液体积 (微升)
|
||||||
|
location: 排液位置信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 排液是否成功
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
self.logger.error("设备未连接,无法执行排液操作")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(f"正在执行排液操作: 体积={volume}μL, 位置={location}")
|
||||||
|
# 这里应该实现实际的排液逻辑
|
||||||
|
self.logger.info("排液操作完成")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"排液操作失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def pick_up_tip(self, location: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
取枪头操作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 枪头位置信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 取枪头是否成功
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
self.logger.error("设备未连接,无法执行取枪头操作")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(f"正在执行取枪头操作: 位置={location}")
|
||||||
|
# 这里应该实现实际的取枪头逻辑
|
||||||
|
self.logger.info("取枪头操作完成")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"取枪头操作失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def drop_tip(self, location: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
丢弃枪头操作
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 丢弃位置信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 丢弃枪头是否成功
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
self.logger.error("设备未连接,无法执行丢弃枪头操作")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(f"正在执行丢弃枪头操作: 位置={location}")
|
||||||
|
# 这里应该实现实际的丢弃枪头逻辑
|
||||||
|
self.logger.info("丢弃枪头操作完成")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"丢弃枪头操作失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def move_to(self, location: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
移动到指定位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: 目标位置信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 移动是否成功
|
||||||
|
"""
|
||||||
|
if not self.is_connected:
|
||||||
|
self.logger.error("设备未连接,无法执行移动操作")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info(f"正在移动到位置: {location}")
|
||||||
|
# 这里应该实现实际的移动逻辑
|
||||||
|
self.logger.info("移动操作完成")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"移动操作失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_status(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
获取设备状态
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 设备状态信息
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"connected": self.is_connected,
|
||||||
|
"device_info": self.device_info,
|
||||||
|
"status": "ready" if self.is_connected else "disconnected"
|
||||||
|
}
|
||||||
|
|
||||||
|
# PyLabRobot 抽象方法实现
|
||||||
|
def stop(self):
|
||||||
|
"""停止所有操作"""
|
||||||
|
self.logger.info("停止所有操作")
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_channels(self) -> int:
|
||||||
|
"""返回通道数量"""
|
||||||
|
return 1 # 单通道移液器
|
||||||
|
|
||||||
|
def can_pick_up_tip(self, tip_rack, tip_position) -> bool:
|
||||||
|
"""检查是否可以拾取吸头"""
|
||||||
|
return True # 简化实现,总是返回True
|
||||||
|
|
||||||
|
def pick_up_tips(self, tip_rack, tip_positions):
|
||||||
|
"""拾取多个吸头"""
|
||||||
|
self.logger.info(f"拾取吸头: {tip_positions}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def drop_tips(self, tip_rack, tip_positions):
|
||||||
|
"""丢弃多个吸头"""
|
||||||
|
self.logger.info(f"丢弃吸头: {tip_positions}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pick_up_tips96(self, tip_rack):
|
||||||
|
"""拾取96个吸头"""
|
||||||
|
self.logger.info("拾取96个吸头")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def drop_tips96(self, tip_rack):
|
||||||
|
"""丢弃96个吸头"""
|
||||||
|
self.logger.info("丢弃96个吸头")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def aspirate96(self, volume, plate, well_positions):
|
||||||
|
"""96通道吸液"""
|
||||||
|
self.logger.info(f"96通道吸液: 体积={volume}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def dispense96(self, volume, plate, well_positions):
|
||||||
|
"""96通道排液"""
|
||||||
|
self.logger.info(f"96通道排液: 体积={volume}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pick_up_resource(self, resource, location):
|
||||||
|
"""拾取资源"""
|
||||||
|
self.logger.info(f"拾取资源: {resource}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def drop_resource(self, resource, location):
|
||||||
|
"""放置资源"""
|
||||||
|
self.logger.info(f"放置资源: {resource}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def move_picked_up_resource(self, resource, location):
|
||||||
|
"""移动已拾取的资源"""
|
||||||
|
self.logger.info(f"移动资源: {resource} 到 {location}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create_laiyu_backend(name: str = "LaiYu_Liquid_Backend") -> LaiYuLiquidBackend:
|
||||||
|
"""
|
||||||
|
创建LaiYu液体处理设备后端实例
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 后端名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuLiquidBackend: 后端实例
|
||||||
|
"""
|
||||||
|
return LaiYuLiquidBackend(name)
|
||||||
209
unilabos/devices/laiyu_liquid/backend/rviz_backend.py
Normal file
209
unilabos/devices/laiyu_liquid/backend/rviz_backend.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
|
||||||
|
import json
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
|
from pylabrobot.liquid_handling.backends.backend import (
|
||||||
|
LiquidHandlerBackend,
|
||||||
|
)
|
||||||
|
from pylabrobot.liquid_handling.standard import (
|
||||||
|
Drop,
|
||||||
|
DropTipRack,
|
||||||
|
MultiHeadAspirationContainer,
|
||||||
|
MultiHeadAspirationPlate,
|
||||||
|
MultiHeadDispenseContainer,
|
||||||
|
MultiHeadDispensePlate,
|
||||||
|
Pickup,
|
||||||
|
PickupTipRack,
|
||||||
|
ResourceDrop,
|
||||||
|
ResourceMove,
|
||||||
|
ResourcePickup,
|
||||||
|
SingleChannelAspiration,
|
||||||
|
SingleChannelDispense,
|
||||||
|
)
|
||||||
|
from pylabrobot.resources import Resource, Tip
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from sensor_msgs.msg import JointState
|
||||||
|
import time
|
||||||
|
from rclpy.action import ActionClient
|
||||||
|
from unilabos_msgs.action import SendCmd
|
||||||
|
import re
|
||||||
|
|
||||||
|
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher
|
||||||
|
|
||||||
|
|
||||||
|
class LiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||||
|
"""Chatter box backend for device-free testing. Prints out all operations."""
|
||||||
|
|
||||||
|
_pip_length = 5
|
||||||
|
_vol_length = 8
|
||||||
|
_resource_length = 20
|
||||||
|
_offset_length = 16
|
||||||
|
_flow_rate_length = 10
|
||||||
|
_blowout_length = 10
|
||||||
|
_lld_z_length = 10
|
||||||
|
_kwargs_length = 15
|
||||||
|
_tip_type_length = 12
|
||||||
|
_max_volume_length = 16
|
||||||
|
_fitting_depth_length = 20
|
||||||
|
_tip_length_length = 16
|
||||||
|
# _pickup_method_length = 20
|
||||||
|
_filter_length = 10
|
||||||
|
|
||||||
|
def __init__(self, num_channels: int = 8):
|
||||||
|
"""Initialize a chatter box backend."""
|
||||||
|
super().__init__()
|
||||||
|
self._num_channels = num_channels
|
||||||
|
# rclpy.init()
|
||||||
|
if not rclpy.ok():
|
||||||
|
rclpy.init()
|
||||||
|
self.joint_state_publisher = None
|
||||||
|
|
||||||
|
async def setup(self):
|
||||||
|
self.joint_state_publisher = JointStatePublisher()
|
||||||
|
await super().setup()
|
||||||
|
async def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def serialize(self) -> dict:
|
||||||
|
return {**super().serialize(), "num_channels": self.num_channels}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_channels(self) -> int:
|
||||||
|
return self._num_channels
|
||||||
|
|
||||||
|
async def assigned_resource_callback(self, resource: Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def unassigned_resource_callback(self, name: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
|
||||||
|
|
||||||
|
for op, channel in zip(ops, use_channels):
|
||||||
|
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}"
|
||||||
|
row = (
|
||||||
|
f" p{channel}: "
|
||||||
|
f"{op.resource.name[-30:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{op.tip.__class__.__name__:<{LiquidHandlerRvizBackend._tip_type_length}} "
|
||||||
|
f"{op.tip.maximal_volume:<{LiquidHandlerRvizBackend._max_volume_length}} "
|
||||||
|
f"{op.tip.fitting_depth:<{LiquidHandlerRvizBackend._fitting_depth_length}} "
|
||||||
|
f"{op.tip.total_tip_length:<{LiquidHandlerRvizBackend._tip_length_length}} "
|
||||||
|
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
|
||||||
|
f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerRvizBackend._filter_length}}"
|
||||||
|
)
|
||||||
|
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||||
|
x = coordinate.x
|
||||||
|
y = coordinate.y
|
||||||
|
z = coordinate.z + 70
|
||||||
|
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick")
|
||||||
|
# goback()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
|
||||||
|
|
||||||
|
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||||
|
x = coordinate.x
|
||||||
|
y = coordinate.y
|
||||||
|
z = coordinate.z + 70
|
||||||
|
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "drop_trash")
|
||||||
|
# goback()
|
||||||
|
|
||||||
|
async def aspirate(
|
||||||
|
self,
|
||||||
|
ops: List[SingleChannelAspiration],
|
||||||
|
use_channels: List[int],
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
# 执行吸液操作
|
||||||
|
pass
|
||||||
|
|
||||||
|
for o, p in zip(ops, use_channels):
|
||||||
|
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||||
|
row = (
|
||||||
|
f" p{p}: "
|
||||||
|
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
|
||||||
|
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
|
||||||
|
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
|
||||||
|
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
|
||||||
|
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||||
|
)
|
||||||
|
for key, value in backend_kwargs.items():
|
||||||
|
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||||
|
value = "".join("T" if v else "F" for v in value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = "".join(map(str, value))
|
||||||
|
row += f" {value:<15}"
|
||||||
|
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||||
|
x = coordinate.x
|
||||||
|
y = coordinate.y
|
||||||
|
z = coordinate.z + 70
|
||||||
|
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
|
||||||
|
|
||||||
|
|
||||||
|
async def dispense(
|
||||||
|
self,
|
||||||
|
ops: List[SingleChannelDispense],
|
||||||
|
use_channels: List[int],
|
||||||
|
**backend_kwargs,
|
||||||
|
):
|
||||||
|
|
||||||
|
for o, p in zip(ops, use_channels):
|
||||||
|
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
|
||||||
|
row = (
|
||||||
|
f" p{p}: "
|
||||||
|
f"{o.volume:<{LiquidHandlerRvizBackend._vol_length}} "
|
||||||
|
f"{o.resource.name[-20:]:<{LiquidHandlerRvizBackend._resource_length}} "
|
||||||
|
f"{offset:<{LiquidHandlerRvizBackend._offset_length}} "
|
||||||
|
f"{str(o.flow_rate):<{LiquidHandlerRvizBackend._flow_rate_length}} "
|
||||||
|
f"{str(o.blow_out_air_volume):<{LiquidHandlerRvizBackend._blowout_length}} "
|
||||||
|
f"{str(o.liquid_height):<{LiquidHandlerRvizBackend._lld_z_length}} "
|
||||||
|
# f"{o.liquids if o.liquids is not None else 'none'}"
|
||||||
|
)
|
||||||
|
for key, value in backend_kwargs.items():
|
||||||
|
if isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
||||||
|
value = "".join("T" if v else "F" for v in value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = "".join(map(str, value))
|
||||||
|
row += f" {value:<{LiquidHandlerRvizBackend._kwargs_length}}"
|
||||||
|
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
|
||||||
|
x = coordinate.x
|
||||||
|
y = coordinate.y
|
||||||
|
z = coordinate.z + 70
|
||||||
|
self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "")
|
||||||
|
|
||||||
|
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def aspirate96(
|
||||||
|
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def pick_up_resource(self, pickup: ResourcePickup):
|
||||||
|
# 执行资源拾取操作
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def move_picked_up_resource(self, move: ResourceMove):
|
||||||
|
# 执行资源移动操作
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def drop_resource(self, drop: ResourceDrop):
|
||||||
|
# 执行资源放置操作
|
||||||
|
pass
|
||||||
|
|
||||||
|
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
2620
unilabos/devices/laiyu_liquid/config/deckconfig.json
Normal file
2620
unilabos/devices/laiyu_liquid/config/deckconfig.json
Normal file
File diff suppressed because it is too large
Load Diff
14
unilabos/devices/laiyu_liquid/config/deckconfig.md
Normal file
14
unilabos/devices/laiyu_liquid/config/deckconfig.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
goto 171 178 57 H1
|
||||||
|
goto 171 117 57 A1
|
||||||
|
goto 172 178 130
|
||||||
|
goto 173 179 133
|
||||||
|
goto 173 180 133
|
||||||
|
goto 173 180 138
|
||||||
|
goto 173 180 125 (+10mm,在空的上面边缘)
|
||||||
|
goto 173 180 130 取不到
|
||||||
|
goto 173 180 133 取不到
|
||||||
|
goto 173 180 135
|
||||||
|
goto 173 180 137 取到了!!!!
|
||||||
|
goto 173 180 131 弹出枪头 H1
|
||||||
|
|
||||||
|
goto 173 117 137 A1 (+10mm,可以取到新枪头了!!!!)
|
||||||
25
unilabos/devices/laiyu_liquid/controllers/__init__.py
Normal file
25
unilabos/devices/laiyu_liquid/controllers/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
LaiYu_Liquid 控制器模块
|
||||||
|
|
||||||
|
该模块包含了LaiYu_Liquid液体处理工作站的高级控制器:
|
||||||
|
- 移液器控制器:提供液体处理的高级接口
|
||||||
|
- XYZ运动控制器:提供三轴运动的高级接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 移液器控制器导入
|
||||||
|
from .pipette_controller import PipetteController
|
||||||
|
|
||||||
|
# XYZ运动控制器导入
|
||||||
|
from .xyz_controller import XYZController
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# 移液器控制器
|
||||||
|
"PipetteController",
|
||||||
|
|
||||||
|
# XYZ运动控制器
|
||||||
|
"XYZController",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "LaiYu_Liquid Controller Team"
|
||||||
|
__description__ = "LaiYu_Liquid 高级控制器集合"
|
||||||
1073
unilabos/devices/laiyu_liquid/controllers/pipette_controller.py
Normal file
1073
unilabos/devices/laiyu_liquid/controllers/pipette_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
1183
unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
Normal file
1183
unilabos/devices/laiyu_liquid/controllers/xyz_controller.py
Normal file
File diff suppressed because it is too large
Load Diff
44
unilabos/devices/laiyu_liquid/core/__init__.py
Normal file
44
unilabos/devices/laiyu_liquid/core/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
LaiYu液体处理设备核心模块
|
||||||
|
|
||||||
|
该模块包含LaiYu液体处理设备的核心功能组件:
|
||||||
|
- LaiYu_Liquid.py: 主设备类和配置管理
|
||||||
|
- abstract_protocol.py: 抽象协议定义
|
||||||
|
- laiyu_liquid_res.py: 设备资源管理
|
||||||
|
|
||||||
|
作者: UniLab团队
|
||||||
|
版本: 2.0.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .laiyu_liquid_main import (
|
||||||
|
LaiYuLiquid,
|
||||||
|
LaiYuLiquidConfig,
|
||||||
|
LaiYuLiquidBackend,
|
||||||
|
LaiYuLiquidDeck,
|
||||||
|
LaiYuLiquidContainer,
|
||||||
|
LaiYuLiquidTipRack,
|
||||||
|
create_quick_setup
|
||||||
|
)
|
||||||
|
|
||||||
|
from .laiyu_liquid_res import (
|
||||||
|
LaiYuLiquidDeck,
|
||||||
|
LaiYuLiquidContainer,
|
||||||
|
LaiYuLiquidTipRack
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# 主设备类
|
||||||
|
'LaiYuLiquid',
|
||||||
|
'LaiYuLiquidConfig',
|
||||||
|
'LaiYuLiquidBackend',
|
||||||
|
|
||||||
|
# 设备资源
|
||||||
|
'LaiYuLiquidDeck',
|
||||||
|
'LaiYuLiquidContainer',
|
||||||
|
'LaiYuLiquidTipRack',
|
||||||
|
|
||||||
|
# 工具函数
|
||||||
|
'create_quick_setup'
|
||||||
|
]
|
||||||
529
unilabos/devices/laiyu_liquid/core/abstract_protocol.py
Normal file
529
unilabos/devices/laiyu_liquid/core/abstract_protocol.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
"""
|
||||||
|
LaiYu_Liquid 抽象协议实现
|
||||||
|
|
||||||
|
该模块提供了液体资源管理和转移的抽象协议,包括:
|
||||||
|
- MaterialResource: 液体资源管理类
|
||||||
|
- transfer_liquid: 液体转移函数
|
||||||
|
- 相关的辅助类和函数
|
||||||
|
|
||||||
|
主要功能:
|
||||||
|
- 管理多孔位的液体资源
|
||||||
|
- 计算和跟踪液体体积
|
||||||
|
- 处理液体转移操作
|
||||||
|
- 提供资源状态查询
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional, Union, Any, Tuple
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
|
||||||
|
# pylabrobot 导入
|
||||||
|
from pylabrobot.resources import Resource, Well, Plate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LiquidType(Enum):
|
||||||
|
"""液体类型枚举"""
|
||||||
|
WATER = "water"
|
||||||
|
ETHANOL = "ethanol"
|
||||||
|
DMSO = "dmso"
|
||||||
|
BUFFER = "buffer"
|
||||||
|
SAMPLE = "sample"
|
||||||
|
REAGENT = "reagent"
|
||||||
|
WASTE = "waste"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LiquidInfo:
|
||||||
|
"""液体信息类"""
|
||||||
|
liquid_type: LiquidType = LiquidType.UNKNOWN
|
||||||
|
volume: float = 0.0 # 体积 (μL)
|
||||||
|
concentration: Optional[float] = None # 浓度 (mg/ml, M等)
|
||||||
|
ph: Optional[float] = None # pH值
|
||||||
|
temperature: Optional[float] = None # 温度 (°C)
|
||||||
|
viscosity: Optional[float] = None # 粘度 (cP)
|
||||||
|
density: Optional[float] = None # 密度 (g/ml)
|
||||||
|
description: str = "" # 描述信息
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.liquid_type.value}({self.description})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WellContent:
|
||||||
|
"""孔位内容类"""
|
||||||
|
volume: float = 0.0 # 当前体积 (ul)
|
||||||
|
max_volume: float = 1000.0 # 最大容量 (ul)
|
||||||
|
liquid_info: LiquidInfo = field(default_factory=LiquidInfo)
|
||||||
|
last_updated: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
"""检查是否为空"""
|
||||||
|
return self.volume <= 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_full(self) -> bool:
|
||||||
|
"""检查是否已满"""
|
||||||
|
return self.volume >= self.max_volume
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_volume(self) -> float:
|
||||||
|
"""可用体积"""
|
||||||
|
return max(0.0, self.max_volume - self.volume)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fill_percentage(self) -> float:
|
||||||
|
"""填充百分比"""
|
||||||
|
return (self.volume / self.max_volume) * 100.0 if self.max_volume > 0 else 0.0
|
||||||
|
|
||||||
|
def can_add_volume(self, volume: float) -> bool:
|
||||||
|
"""检查是否可以添加指定体积"""
|
||||||
|
return (self.volume + volume) <= self.max_volume
|
||||||
|
|
||||||
|
def can_remove_volume(self, volume: float) -> bool:
|
||||||
|
"""检查是否可以移除指定体积"""
|
||||||
|
return self.volume >= volume
|
||||||
|
|
||||||
|
def add_volume(self, volume: float, liquid_info: Optional[LiquidInfo] = None) -> bool:
|
||||||
|
"""
|
||||||
|
添加液体体积
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volume: 要添加的体积 (ul)
|
||||||
|
liquid_info: 液体信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功添加
|
||||||
|
"""
|
||||||
|
if not self.can_add_volume(volume):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.volume += volume
|
||||||
|
if liquid_info:
|
||||||
|
self.liquid_info = liquid_info
|
||||||
|
self.last_updated = time.time()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_volume(self, volume: float) -> bool:
|
||||||
|
"""
|
||||||
|
移除液体体积
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volume: 要移除的体积 (ul)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功移除
|
||||||
|
"""
|
||||||
|
if not self.can_remove_volume(volume):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.volume -= volume
|
||||||
|
self.last_updated = time.time()
|
||||||
|
|
||||||
|
# 如果完全清空,重置液体信息
|
||||||
|
if self.volume <= 0.0:
|
||||||
|
self.volume = 0.0
|
||||||
|
self.liquid_info = LiquidInfo()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class MaterialResource:
|
||||||
|
"""
|
||||||
|
液体资源管理类
|
||||||
|
|
||||||
|
该类用于管理液体处理过程中的资源状态,包括:
|
||||||
|
- 跟踪多个孔位的液体体积和类型
|
||||||
|
- 计算总体积和可用体积
|
||||||
|
- 处理液体的添加和移除
|
||||||
|
- 提供资源状态查询
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
resource: Resource,
|
||||||
|
wells: Optional[List[Well]] = None,
|
||||||
|
default_max_volume: float = 1000.0
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化材料资源
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resource: pylabrobot 资源对象
|
||||||
|
wells: 孔位列表,如果为None则自动获取
|
||||||
|
default_max_volume: 默认最大体积 (ul)
|
||||||
|
"""
|
||||||
|
self.resource = resource
|
||||||
|
self.resource_id = str(uuid.uuid4())
|
||||||
|
self.default_max_volume = default_max_volume
|
||||||
|
|
||||||
|
# 获取孔位列表
|
||||||
|
if wells is None:
|
||||||
|
if hasattr(resource, 'get_wells'):
|
||||||
|
self.wells = resource.get_wells()
|
||||||
|
elif hasattr(resource, 'wells'):
|
||||||
|
self.wells = resource.wells
|
||||||
|
else:
|
||||||
|
# 如果没有孔位,创建一个虚拟孔位
|
||||||
|
self.wells = [resource]
|
||||||
|
else:
|
||||||
|
self.wells = wells
|
||||||
|
|
||||||
|
# 初始化孔位内容
|
||||||
|
self.well_contents: Dict[str, WellContent] = {}
|
||||||
|
for well in self.wells:
|
||||||
|
well_id = self._get_well_id(well)
|
||||||
|
self.well_contents[well_id] = WellContent(
|
||||||
|
max_volume=default_max_volume
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"初始化材料资源: {resource.name}, 孔位数: {len(self.wells)}")
|
||||||
|
|
||||||
|
def _get_well_id(self, well: Union[Well, Resource]) -> str:
|
||||||
|
"""获取孔位ID"""
|
||||||
|
if hasattr(well, 'name'):
|
||||||
|
return well.name
|
||||||
|
else:
|
||||||
|
return str(id(well))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""资源名称"""
|
||||||
|
return self.resource.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_volume(self) -> float:
|
||||||
|
"""总液体体积"""
|
||||||
|
return sum(content.volume for content in self.well_contents.values())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_max_volume(self) -> float:
|
||||||
|
"""总最大容量"""
|
||||||
|
return sum(content.max_volume for content in self.well_contents.values())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_volume(self) -> float:
|
||||||
|
"""总可用体积"""
|
||||||
|
return sum(content.available_volume for content in self.well_contents.values())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def well_count(self) -> int:
|
||||||
|
"""孔位数量"""
|
||||||
|
return len(self.wells)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def empty_wells(self) -> List[str]:
|
||||||
|
"""空孔位列表"""
|
||||||
|
return [well_id for well_id, content in self.well_contents.items()
|
||||||
|
if content.is_empty]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_wells(self) -> List[str]:
|
||||||
|
"""满孔位列表"""
|
||||||
|
return [well_id for well_id, content in self.well_contents.items()
|
||||||
|
if content.is_full]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def occupied_wells(self) -> List[str]:
|
||||||
|
"""有液体的孔位列表"""
|
||||||
|
return [well_id for well_id, content in self.well_contents.items()
|
||||||
|
if not content.is_empty]
|
||||||
|
|
||||||
|
def get_well_content(self, well_id: str) -> Optional[WellContent]:
|
||||||
|
"""获取指定孔位的内容"""
|
||||||
|
return self.well_contents.get(well_id)
|
||||||
|
|
||||||
|
def get_well_volume(self, well_id: str) -> float:
|
||||||
|
"""获取指定孔位的体积"""
|
||||||
|
content = self.get_well_content(well_id)
|
||||||
|
return content.volume if content else 0.0
|
||||||
|
|
||||||
|
def set_well_volume(
|
||||||
|
self,
|
||||||
|
well_id: str,
|
||||||
|
volume: float,
|
||||||
|
liquid_info: Optional[LiquidInfo] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
设置指定孔位的体积
|
||||||
|
|
||||||
|
Args:
|
||||||
|
well_id: 孔位ID
|
||||||
|
volume: 体积 (ul)
|
||||||
|
liquid_info: 液体信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功设置
|
||||||
|
"""
|
||||||
|
if well_id not in self.well_contents:
|
||||||
|
logger.error(f"孔位 {well_id} 不存在")
|
||||||
|
return False
|
||||||
|
|
||||||
|
content = self.well_contents[well_id]
|
||||||
|
if volume > content.max_volume:
|
||||||
|
logger.error(f"体积 {volume} 超过最大容量 {content.max_volume}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
content.volume = max(0.0, volume)
|
||||||
|
if liquid_info:
|
||||||
|
content.liquid_info = liquid_info
|
||||||
|
content.last_updated = time.time()
|
||||||
|
|
||||||
|
logger.info(f"设置孔位 {well_id} 体积: {volume}ul")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def add_liquid(
|
||||||
|
self,
|
||||||
|
well_id: str,
|
||||||
|
volume: float,
|
||||||
|
liquid_info: Optional[LiquidInfo] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
向指定孔位添加液体
|
||||||
|
|
||||||
|
Args:
|
||||||
|
well_id: 孔位ID
|
||||||
|
volume: 添加的体积 (ul)
|
||||||
|
liquid_info: 液体信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功添加
|
||||||
|
"""
|
||||||
|
if well_id not in self.well_contents:
|
||||||
|
logger.error(f"孔位 {well_id} 不存在")
|
||||||
|
return False
|
||||||
|
|
||||||
|
content = self.well_contents[well_id]
|
||||||
|
success = content.add_volume(volume, liquid_info)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"向孔位 {well_id} 添加 {volume}ul 液体")
|
||||||
|
else:
|
||||||
|
logger.error(f"无法向孔位 {well_id} 添加 {volume}ul 液体")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def remove_liquid(self, well_id: str, volume: float) -> bool:
|
||||||
|
"""
|
||||||
|
从指定孔位移除液体
|
||||||
|
|
||||||
|
Args:
|
||||||
|
well_id: 孔位ID
|
||||||
|
volume: 移除的体积 (ul)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功移除
|
||||||
|
"""
|
||||||
|
if well_id not in self.well_contents:
|
||||||
|
logger.error(f"孔位 {well_id} 不存在")
|
||||||
|
return False
|
||||||
|
|
||||||
|
content = self.well_contents[well_id]
|
||||||
|
success = content.remove_volume(volume)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"从孔位 {well_id} 移除 {volume}ul 液体")
|
||||||
|
else:
|
||||||
|
logger.error(f"无法从孔位 {well_id} 移除 {volume}ul 液体")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
def find_wells_with_volume(self, min_volume: float) -> List[str]:
|
||||||
|
"""
|
||||||
|
查找具有指定最小体积的孔位
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_volume: 最小体积 (ul)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: 符合条件的孔位ID列表
|
||||||
|
"""
|
||||||
|
return [well_id for well_id, content in self.well_contents.items()
|
||||||
|
if content.volume >= min_volume]
|
||||||
|
|
||||||
|
def find_wells_with_space(self, min_space: float) -> List[str]:
|
||||||
|
"""
|
||||||
|
查找具有指定最小空间的孔位
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_space: 最小空间 (ul)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: 符合条件的孔位ID列表
|
||||||
|
"""
|
||||||
|
return [well_id for well_id, content in self.well_contents.items()
|
||||||
|
if content.available_volume >= min_space]
|
||||||
|
|
||||||
|
def get_status_summary(self) -> Dict[str, Any]:
|
||||||
|
"""获取资源状态摘要"""
|
||||||
|
return {
|
||||||
|
"resource_name": self.name,
|
||||||
|
"resource_id": self.resource_id,
|
||||||
|
"well_count": self.well_count,
|
||||||
|
"total_volume": self.total_volume,
|
||||||
|
"total_max_volume": self.total_max_volume,
|
||||||
|
"available_volume": self.available_volume,
|
||||||
|
"fill_percentage": (self.total_volume / self.total_max_volume) * 100.0,
|
||||||
|
"empty_wells": len(self.empty_wells),
|
||||||
|
"full_wells": len(self.full_wells),
|
||||||
|
"occupied_wells": len(self.occupied_wells)
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_detailed_status(self) -> Dict[str, Any]:
|
||||||
|
"""获取详细状态信息"""
|
||||||
|
well_details = {}
|
||||||
|
for well_id, content in self.well_contents.items():
|
||||||
|
well_details[well_id] = {
|
||||||
|
"volume": content.volume,
|
||||||
|
"max_volume": content.max_volume,
|
||||||
|
"available_volume": content.available_volume,
|
||||||
|
"fill_percentage": content.fill_percentage,
|
||||||
|
"liquid_type": content.liquid_info.liquid_type.value,
|
||||||
|
"description": content.liquid_info.description,
|
||||||
|
"last_updated": content.last_updated
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": self.get_status_summary(),
|
||||||
|
"wells": well_details
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_liquid(
|
||||||
|
source: MaterialResource,
|
||||||
|
target: MaterialResource,
|
||||||
|
volume: float,
|
||||||
|
source_well_id: Optional[str] = None,
|
||||||
|
target_well_id: Optional[str] = None,
|
||||||
|
liquid_info: Optional[LiquidInfo] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
在两个材料资源之间转移液体
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: 源资源
|
||||||
|
target: 目标资源
|
||||||
|
volume: 转移体积 (ul)
|
||||||
|
source_well_id: 源孔位ID,如果为None则自动选择
|
||||||
|
target_well_id: 目标孔位ID,如果为None则自动选择
|
||||||
|
liquid_info: 液体信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 转移是否成功
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 自动选择源孔位
|
||||||
|
if source_well_id is None:
|
||||||
|
available_wells = source.find_wells_with_volume(volume)
|
||||||
|
if not available_wells:
|
||||||
|
logger.error(f"源资源 {source.name} 没有足够体积的孔位")
|
||||||
|
return False
|
||||||
|
source_well_id = available_wells[0]
|
||||||
|
|
||||||
|
# 自动选择目标孔位
|
||||||
|
if target_well_id is None:
|
||||||
|
available_wells = target.find_wells_with_space(volume)
|
||||||
|
if not available_wells:
|
||||||
|
logger.error(f"目标资源 {target.name} 没有足够空间的孔位")
|
||||||
|
return False
|
||||||
|
target_well_id = available_wells[0]
|
||||||
|
|
||||||
|
# 检查源孔位是否有足够液体
|
||||||
|
if not source.get_well_content(source_well_id).can_remove_volume(volume):
|
||||||
|
logger.error(f"源孔位 {source_well_id} 液体不足")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查目标孔位是否有足够空间
|
||||||
|
if not target.get_well_content(target_well_id).can_add_volume(volume):
|
||||||
|
logger.error(f"目标孔位 {target_well_id} 空间不足")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 获取源液体信息
|
||||||
|
source_content = source.get_well_content(source_well_id)
|
||||||
|
transfer_liquid_info = liquid_info or source_content.liquid_info
|
||||||
|
|
||||||
|
# 执行转移
|
||||||
|
if source.remove_liquid(source_well_id, volume):
|
||||||
|
if target.add_liquid(target_well_id, volume, transfer_liquid_info):
|
||||||
|
logger.info(f"成功转移 {volume}ul 液体: {source.name}[{source_well_id}] -> {target.name}[{target_well_id}]")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# 如果目标添加失败,回滚源操作
|
||||||
|
source.add_liquid(source_well_id, volume, source_content.liquid_info)
|
||||||
|
logger.error("目标添加失败,已回滚源操作")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.error("源移除失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"液体转移失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_material_resource(
|
||||||
|
name: str,
|
||||||
|
resource: Resource,
|
||||||
|
initial_volumes: Optional[Dict[str, float]] = None,
|
||||||
|
liquid_info: Optional[LiquidInfo] = None,
|
||||||
|
max_volume: float = 1000.0
|
||||||
|
) -> MaterialResource:
|
||||||
|
"""
|
||||||
|
创建材料资源的便捷函数
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 资源名称
|
||||||
|
resource: pylabrobot 资源对象
|
||||||
|
initial_volumes: 初始体积字典 {well_id: volume}
|
||||||
|
liquid_info: 液体信息
|
||||||
|
max_volume: 最大体积
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MaterialResource: 创建的材料资源
|
||||||
|
"""
|
||||||
|
material_resource = MaterialResource(
|
||||||
|
resource=resource,
|
||||||
|
default_max_volume=max_volume
|
||||||
|
)
|
||||||
|
|
||||||
|
# 设置初始体积
|
||||||
|
if initial_volumes:
|
||||||
|
for well_id, volume in initial_volumes.items():
|
||||||
|
material_resource.set_well_volume(well_id, volume, liquid_info)
|
||||||
|
|
||||||
|
return material_resource
|
||||||
|
|
||||||
|
|
||||||
|
def batch_transfer_liquid(
|
||||||
|
transfers: List[Tuple[MaterialResource, MaterialResource, float]],
|
||||||
|
liquid_info: Optional[LiquidInfo] = None
|
||||||
|
) -> List[bool]:
|
||||||
|
"""
|
||||||
|
批量液体转移
|
||||||
|
|
||||||
|
Args:
|
||||||
|
transfers: 转移列表 [(source, target, volume), ...]
|
||||||
|
liquid_info: 液体信息
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[bool]: 每个转移操作的结果
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for source, target, volume in transfers:
|
||||||
|
result = transfer_liquid(source, target, volume, liquid_info=liquid_info)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
logger.warning(f"批量转移中的操作失败: {source.name} -> {target.name}")
|
||||||
|
|
||||||
|
success_count = sum(results)
|
||||||
|
logger.info(f"批量转移完成: {success_count}/{len(transfers)} 成功")
|
||||||
|
|
||||||
|
return results
|
||||||
888
unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py
Normal file
888
unilabos/devices/laiyu_liquid/core/laiyu_liquid_main.py
Normal file
@@ -0,0 +1,888 @@
|
|||||||
|
"""
|
||||||
|
LaiYu_Liquid 液体处理工作站主要集成文件
|
||||||
|
|
||||||
|
该模块实现了 LaiYu_Liquid 与 UniLabOS 系统的集成,提供标准化的液体处理接口。
|
||||||
|
主要包含:
|
||||||
|
- LaiYuLiquidBackend: 硬件通信后端
|
||||||
|
- LaiYuLiquid: 主要接口类
|
||||||
|
- 相关的异常类和容器类
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import List, Optional, Dict, Any, Union, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||||
|
|
||||||
|
# 基础导入
|
||||||
|
try:
|
||||||
|
from pylabrobot.resources import Deck, Plate, TipRack, Tip, Resource, Well
|
||||||
|
|
||||||
|
PYLABROBOT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
# 如果 pylabrobot 不可用,创建基础的模拟类
|
||||||
|
PYLABROBOT_AVAILABLE = False
|
||||||
|
|
||||||
|
class Resource:
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
class Deck(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Plate(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TipRack(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Tip(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Well(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# LaiYu_Liquid 控制器导入
|
||||||
|
try:
|
||||||
|
from .controllers.pipette_controller import PipetteController, TipStatus, LiquidClass, LiquidParameters
|
||||||
|
from .controllers.xyz_controller import XYZController, MachineConfig, CoordinateOrigin, MotorAxis
|
||||||
|
|
||||||
|
CONTROLLERS_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
CONTROLLERS_AVAILABLE = False
|
||||||
|
|
||||||
|
# 创建模拟的控制器类
|
||||||
|
class PipetteController:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
class XYZController:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect_device(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuLiquidError(RuntimeError):
|
||||||
|
"""LaiYu_Liquid 设备异常"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LaiYuLiquidConfig:
|
||||||
|
"""LaiYu_Liquid 设备配置"""
|
||||||
|
|
||||||
|
port: str = "/dev/cu.usbserial-3130" # RS485转USB端口
|
||||||
|
address: int = 1 # 设备地址
|
||||||
|
baudrate: int = 9600 # 波特率
|
||||||
|
timeout: float = 5.0 # 通信超时时间
|
||||||
|
|
||||||
|
# 工作台尺寸
|
||||||
|
deck_width: float = 340.0 # 工作台宽度 (mm)
|
||||||
|
deck_height: float = 250.0 # 工作台高度 (mm)
|
||||||
|
deck_depth: float = 160.0 # 工作台深度 (mm)
|
||||||
|
|
||||||
|
# 移液参数
|
||||||
|
max_volume: float = 1000.0 # 最大体积 (μL)
|
||||||
|
min_volume: float = 0.1 # 最小体积 (μL)
|
||||||
|
|
||||||
|
# 运动参数
|
||||||
|
max_speed: float = 100.0 # 最大速度 (mm/s)
|
||||||
|
acceleration: float = 50.0 # 加速度 (mm/s²)
|
||||||
|
|
||||||
|
# 安全参数
|
||||||
|
safe_height: float = 50.0 # 安全高度 (mm)
|
||||||
|
tip_pickup_depth: float = 10.0 # 吸头拾取深度 (mm)
|
||||||
|
liquid_detection: bool = True # 液面检测
|
||||||
|
|
||||||
|
# 取枪头相关参数
|
||||||
|
tip_pickup_speed: int = 30 # 取枪头时的移动速度 (rpm)
|
||||||
|
tip_pickup_acceleration: int = 500 # 取枪头时的加速度 (rpm/s)
|
||||||
|
tip_approach_height: float = 5.0 # 接近枪头时的高度 (mm)
|
||||||
|
tip_pickup_force_depth: float = 2.0 # 强制插入深度 (mm)
|
||||||
|
tip_pickup_retract_height: float = 20.0 # 取枪头后的回退高度 (mm)
|
||||||
|
|
||||||
|
# 丢弃枪头相关参数
|
||||||
|
tip_drop_height: float = 10.0 # 丢弃枪头时的高度 (mm)
|
||||||
|
tip_drop_speed: int = 50 # 丢弃枪头时的移动速度 (rpm)
|
||||||
|
trash_position: Tuple[float, float, float] = (300.0, 200.0, 0.0) # 垃圾桶位置 (mm)
|
||||||
|
|
||||||
|
# 安全范围配置
|
||||||
|
deck_width: float = 300.0 # 工作台宽度 (mm)
|
||||||
|
deck_height: float = 200.0 # 工作台高度 (mm)
|
||||||
|
deck_depth: float = 100.0 # 工作台深度 (mm)
|
||||||
|
safe_height: float = 50.0 # 安全高度 (mm)
|
||||||
|
position_validation: bool = True # 启用位置验证
|
||||||
|
emergency_stop_enabled: bool = True # 启用紧急停止
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuLiquidDeck:
|
||||||
|
"""LaiYu_Liquid 工作台管理"""
|
||||||
|
|
||||||
|
def __init__(self, config: LaiYuLiquidConfig):
|
||||||
|
self.config = config
|
||||||
|
self.resources: Dict[str, Resource] = {}
|
||||||
|
self.positions: Dict[str, Tuple[float, float, float]] = {}
|
||||||
|
|
||||||
|
def add_resource(self, name: str, resource: Resource, position: Tuple[float, float, float]):
|
||||||
|
"""添加资源到工作台"""
|
||||||
|
self.resources[name] = resource
|
||||||
|
self.positions[name] = position
|
||||||
|
|
||||||
|
def get_resource(self, name: str) -> Optional[Resource]:
|
||||||
|
"""获取资源"""
|
||||||
|
return self.resources.get(name)
|
||||||
|
|
||||||
|
def get_position(self, name: str) -> Optional[Tuple[float, float, float]]:
|
||||||
|
"""获取资源位置"""
|
||||||
|
return self.positions.get(name)
|
||||||
|
|
||||||
|
def list_resources(self) -> List[str]:
|
||||||
|
"""列出所有资源"""
|
||||||
|
return list(self.resources.keys())
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuLiquidContainer:
|
||||||
|
"""LaiYu_Liquid 容器类"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float = 0,
|
||||||
|
size_y: float = 0,
|
||||||
|
size_z: float = 0,
|
||||||
|
container_type: str = "",
|
||||||
|
volume: float = 0.0,
|
||||||
|
max_volume: float = 1000.0,
|
||||||
|
lid_height: float = 0.0,
|
||||||
|
):
|
||||||
|
self.name = name
|
||||||
|
self.size_x = size_x
|
||||||
|
self.size_y = size_y
|
||||||
|
self.size_z = size_z
|
||||||
|
self.lid_height = lid_height
|
||||||
|
self.container_type = container_type
|
||||||
|
self.volume = volume
|
||||||
|
self.max_volume = max_volume
|
||||||
|
self.last_updated = time.time()
|
||||||
|
self.child_resources = {} # 存储子资源
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return self.volume <= 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_full(self) -> bool:
|
||||||
|
return self.volume >= self.max_volume
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_volume(self) -> float:
|
||||||
|
return max(0.0, self.max_volume - self.volume)
|
||||||
|
|
||||||
|
def add_volume(self, volume: float) -> bool:
|
||||||
|
"""添加体积"""
|
||||||
|
if self.volume + volume <= self.max_volume:
|
||||||
|
self.volume += volume
|
||||||
|
self.last_updated = time.time()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_volume(self, volume: float) -> bool:
|
||||||
|
"""移除体积"""
|
||||||
|
if self.volume >= volume:
|
||||||
|
self.volume -= volume
|
||||||
|
self.last_updated = time.time()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def assign_child_resource(self, resource, location=None):
|
||||||
|
"""分配子资源 - 与 PyLabRobot 资源管理系统兼容"""
|
||||||
|
if hasattr(resource, "name"):
|
||||||
|
self.child_resources[resource.name] = {"resource": resource, "location": location}
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuLiquidTipRack:
|
||||||
|
"""LaiYu_Liquid 吸头架类"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
size_x: float = 0,
|
||||||
|
size_y: float = 0,
|
||||||
|
size_z: float = 0,
|
||||||
|
tip_count: int = 96,
|
||||||
|
tip_volume: float = 1000.0,
|
||||||
|
):
|
||||||
|
self.name = name
|
||||||
|
self.size_x = size_x
|
||||||
|
self.size_y = size_y
|
||||||
|
self.size_z = size_z
|
||||||
|
self.tip_count = tip_count
|
||||||
|
self.tip_volume = tip_volume
|
||||||
|
self.tips_available = [True] * tip_count
|
||||||
|
self.child_resources = {} # 存储子资源
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_tips(self) -> int:
|
||||||
|
return sum(self.tips_available)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return self.available_tips == 0
|
||||||
|
|
||||||
|
def pick_tip(self, position: int) -> bool:
|
||||||
|
"""拾取吸头"""
|
||||||
|
if 0 <= position < self.tip_count and self.tips_available[position]:
|
||||||
|
self.tips_available[position] = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_tip(self, position: int) -> bool:
|
||||||
|
"""检查位置是否有吸头"""
|
||||||
|
if 0 <= position < self.tip_count:
|
||||||
|
return self.tips_available[position]
|
||||||
|
return False
|
||||||
|
|
||||||
|
def assign_child_resource(self, resource, location=None):
|
||||||
|
"""分配子资源到指定位置"""
|
||||||
|
self.child_resources[resource.name] = {"resource": resource, "location": location}
|
||||||
|
|
||||||
|
|
||||||
|
def get_module_info():
|
||||||
|
"""获取模块信息"""
|
||||||
|
return {
|
||||||
|
"name": "LaiYu_Liquid",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "LaiYu液体处理工作站模块,提供移液器控制、XYZ轴控制和资源管理功能",
|
||||||
|
"author": "UniLabOS Team",
|
||||||
|
"capabilities": ["移液器控制", "XYZ轴运动控制", "吸头架管理", "板和容器管理", "资源位置管理"],
|
||||||
|
"dependencies": {"required": ["serial"], "optional": ["pylabrobot"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuLiquidBackend:
|
||||||
|
"""LaiYu_Liquid 硬件通信后端"""
|
||||||
|
|
||||||
|
_ros_node: BaseROS2DeviceNode
|
||||||
|
|
||||||
|
def __init__(self, config: LaiYuLiquidConfig, deck: Optional["LaiYuLiquidDeck"] = None):
|
||||||
|
self.config = config
|
||||||
|
self.deck = deck # 工作台引用,用于获取资源位置信息
|
||||||
|
self.pipette_controller = None
|
||||||
|
self.xyz_controller = None
|
||||||
|
self.is_connected = False
|
||||||
|
self.is_initialized = False
|
||||||
|
|
||||||
|
# 状态跟踪
|
||||||
|
self.current_position = (0.0, 0.0, 0.0)
|
||||||
|
self.tip_attached = False
|
||||||
|
self.current_volume = 0.0
|
||||||
|
|
||||||
|
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||||
|
self._ros_node = ros_node
|
||||||
|
|
||||||
|
def _validate_position(self, x: float, y: float, z: float) -> bool:
|
||||||
|
"""验证位置是否在安全范围内"""
|
||||||
|
try:
|
||||||
|
# 检查X轴范围
|
||||||
|
if not (0 <= x <= self.config.deck_width):
|
||||||
|
logger.error(f"X轴位置 {x:.2f}mm 超出范围 [0, {self.config.deck_width}]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查Y轴范围
|
||||||
|
if not (0 <= y <= self.config.deck_height):
|
||||||
|
logger.error(f"Y轴位置 {y:.2f}mm 超出范围 [0, {self.config.deck_height}]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 检查Z轴范围(负值表示向下,0为工作台表面)
|
||||||
|
if not (-self.config.deck_depth <= z <= self.config.safe_height):
|
||||||
|
logger.error(f"Z轴位置 {z:.2f}mm 超出安全范围 [{-self.config.deck_depth}, {self.config.safe_height}]")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"位置验证失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_hardware_ready(self) -> bool:
|
||||||
|
"""检查硬件是否准备就绪"""
|
||||||
|
if not self.is_connected:
|
||||||
|
logger.error("设备未连接")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if CONTROLLERS_AVAILABLE:
|
||||||
|
if self.xyz_controller is None:
|
||||||
|
logger.error("XYZ控制器未初始化")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def emergency_stop(self) -> bool:
|
||||||
|
"""紧急停止所有运动"""
|
||||||
|
try:
|
||||||
|
logger.warning("执行紧急停止")
|
||||||
|
|
||||||
|
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||||
|
# 停止XYZ控制器
|
||||||
|
await self.xyz_controller.stop_all_motion()
|
||||||
|
logger.info("XYZ控制器已停止")
|
||||||
|
|
||||||
|
if self.pipette_controller:
|
||||||
|
# 停止移液器控制器
|
||||||
|
await self.pipette_controller.stop()
|
||||||
|
logger.info("移液器控制器已停止")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"紧急停止失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def move_to_safe_position(self) -> bool:
|
||||||
|
"""移动到安全位置"""
|
||||||
|
try:
|
||||||
|
if not self._check_hardware_ready():
|
||||||
|
return False
|
||||||
|
|
||||||
|
safe_position = (
|
||||||
|
self.config.deck_width / 2, # 工作台中心X
|
||||||
|
self.config.deck_height / 2, # 工作台中心Y
|
||||||
|
self.config.safe_height, # 安全高度Z
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._validate_position(*safe_position):
|
||||||
|
logger.error("安全位置无效")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||||
|
await self.xyz_controller.move_to_work_coord(*safe_position)
|
||||||
|
self.current_position = safe_position
|
||||||
|
logger.info(f"已移动到安全位置: {safe_position}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# 模拟模式
|
||||||
|
self.current_position = safe_position
|
||||||
|
logger.info("模拟移动到安全位置")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"移动到安全位置失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def setup(self) -> bool:
|
||||||
|
"""设置硬件连接"""
|
||||||
|
try:
|
||||||
|
if CONTROLLERS_AVAILABLE:
|
||||||
|
# 初始化移液器控制器
|
||||||
|
self.pipette_controller = PipetteController(port=self.config.port, address=self.config.address)
|
||||||
|
|
||||||
|
# 初始化XYZ控制器
|
||||||
|
machine_config = MachineConfig()
|
||||||
|
self.xyz_controller = XYZController(
|
||||||
|
port=self.config.port, baudrate=self.config.baudrate, machine_config=machine_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# 连接设备
|
||||||
|
pipette_connected = await asyncio.to_thread(self.pipette_controller.connect)
|
||||||
|
xyz_connected = await asyncio.to_thread(self.xyz_controller.connect_device)
|
||||||
|
|
||||||
|
if pipette_connected and xyz_connected:
|
||||||
|
self.is_connected = True
|
||||||
|
logger.info("LaiYu_Liquid 硬件连接成功")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error("LaiYu_Liquid 硬件连接失败")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# 模拟模式
|
||||||
|
logger.info("LaiYu_Liquid 运行在模拟模式")
|
||||||
|
self.is_connected = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""停止设备"""
|
||||||
|
try:
|
||||||
|
if self.pipette_controller and hasattr(self.pipette_controller, "disconnect"):
|
||||||
|
await asyncio.to_thread(self.pipette_controller.disconnect)
|
||||||
|
|
||||||
|
if self.xyz_controller and hasattr(self.xyz_controller, "disconnect"):
|
||||||
|
await asyncio.to_thread(self.xyz_controller.disconnect)
|
||||||
|
|
||||||
|
self.is_connected = False
|
||||||
|
self.is_initialized = False
|
||||||
|
logger.info("LaiYu_Liquid 已停止")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LaiYu_Liquid 停止失败: {e}")
|
||||||
|
|
||||||
|
async def move_to(self, x: float, y: float, z: float) -> bool:
|
||||||
|
"""移动到指定位置"""
|
||||||
|
try:
|
||||||
|
if not self.is_connected:
|
||||||
|
raise LaiYuLiquidError("设备未连接")
|
||||||
|
|
||||||
|
# 模拟移动
|
||||||
|
await self._ros_node.sleep(0.1) # 模拟移动时间
|
||||||
|
self.current_position = (x, y, z)
|
||||||
|
logger.debug(f"移动到位置: ({x}, {y}, {z})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"移动失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def pick_up_tip(self, tip_rack: str, position: int) -> bool:
|
||||||
|
"""拾取吸头 - 包含真正的Z轴下降控制"""
|
||||||
|
try:
|
||||||
|
# 硬件准备检查
|
||||||
|
if not self._check_hardware_ready():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.tip_attached:
|
||||||
|
logger.warning("已有吸头附着,无法拾取新吸头")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"开始从 {tip_rack} 位置 {position} 拾取吸头")
|
||||||
|
|
||||||
|
# 获取枪头架位置信息
|
||||||
|
if self.deck is None:
|
||||||
|
logger.error("工作台未初始化")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tip_position = self.deck.get_position(tip_rack)
|
||||||
|
if tip_position is None:
|
||||||
|
logger.error(f"未找到枪头架 {tip_rack} 的位置信息")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 计算具体枪头位置(这里简化处理,实际应根据position计算偏移)
|
||||||
|
tip_x, tip_y, tip_z = tip_position
|
||||||
|
|
||||||
|
# 验证所有关键位置的安全性
|
||||||
|
safe_z = tip_z + self.config.tip_approach_height
|
||||||
|
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||||
|
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||||
|
|
||||||
|
if not (
|
||||||
|
self._validate_position(tip_x, tip_y, safe_z)
|
||||||
|
and self._validate_position(tip_x, tip_y, pickup_z)
|
||||||
|
and self._validate_position(tip_x, tip_y, retract_z)
|
||||||
|
):
|
||||||
|
logger.error("枪头拾取位置超出安全范围")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||||
|
# 真实硬件控制流程
|
||||||
|
logger.info("使用真实XYZ控制器进行枪头拾取")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 移动到枪头上方的安全位置
|
||||||
|
safe_z = tip_z + self.config.tip_approach_height
|
||||||
|
logger.info(f"移动到枪头上方安全位置: ({tip_x:.2f}, {tip_y:.2f}, {safe_z:.2f})")
|
||||||
|
move_success = await asyncio.to_thread(
|
||||||
|
self.xyz_controller.move_to_work_coord, tip_x, tip_y, safe_z
|
||||||
|
)
|
||||||
|
if not move_success:
|
||||||
|
logger.error("移动到枪头上方失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Z轴下降到枪头位置
|
||||||
|
pickup_z = tip_z - self.config.tip_pickup_force_depth
|
||||||
|
logger.info(f"Z轴下降到枪头拾取位置: {pickup_z:.2f}mm")
|
||||||
|
z_down_success = await asyncio.to_thread(
|
||||||
|
self.xyz_controller.move_to_work_coord, tip_x, tip_y, pickup_z
|
||||||
|
)
|
||||||
|
if not z_down_success:
|
||||||
|
logger.error("Z轴下降到枪头位置失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3. 等待一小段时间确保枪头牢固附着
|
||||||
|
await self._ros_node.sleep(0.2)
|
||||||
|
|
||||||
|
# 4. Z轴上升到回退高度
|
||||||
|
retract_z = tip_z + self.config.tip_pickup_retract_height
|
||||||
|
logger.info(f"Z轴上升到回退高度: {retract_z:.2f}mm")
|
||||||
|
z_up_success = await asyncio.to_thread(
|
||||||
|
self.xyz_controller.move_to_work_coord, tip_x, tip_y, retract_z
|
||||||
|
)
|
||||||
|
if not z_up_success:
|
||||||
|
logger.error("Z轴上升失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 5. 更新当前位置
|
||||||
|
self.current_position = (tip_x, tip_y, retract_z)
|
||||||
|
|
||||||
|
except Exception as move_error:
|
||||||
|
logger.error(f"枪头拾取过程中发生错误: {move_error}")
|
||||||
|
# 尝试移动到安全位置
|
||||||
|
if self.config.emergency_stop_enabled:
|
||||||
|
await self.emergency_stop()
|
||||||
|
await self.move_to_safe_position()
|
||||||
|
return False
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 模拟模式
|
||||||
|
logger.info("模拟模式:执行枪头拾取动作")
|
||||||
|
await self._ros_node.sleep(1.0) # 模拟整个拾取过程的时间
|
||||||
|
self.current_position = (tip_x, tip_y, tip_z + self.config.tip_pickup_retract_height)
|
||||||
|
|
||||||
|
# 6. 标记枪头已附着
|
||||||
|
self.tip_attached = True
|
||||||
|
logger.info("吸头拾取成功")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"拾取吸头失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def drop_tip(self, location: str = "trash") -> bool:
|
||||||
|
"""丢弃吸头 - 包含真正的Z轴控制"""
|
||||||
|
try:
|
||||||
|
# 硬件准备检查
|
||||||
|
if not self._check_hardware_ready():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.tip_attached:
|
||||||
|
logger.warning("没有吸头附着,无需丢弃")
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info(f"开始丢弃吸头到 {location}")
|
||||||
|
|
||||||
|
# 确定丢弃位置
|
||||||
|
if location == "trash":
|
||||||
|
# 使用配置中的垃圾桶位置
|
||||||
|
drop_x, drop_y, drop_z = self.config.trash_position
|
||||||
|
else:
|
||||||
|
# 尝试从deck获取指定位置
|
||||||
|
if self.deck is None:
|
||||||
|
logger.error("工作台未初始化")
|
||||||
|
return False
|
||||||
|
|
||||||
|
drop_position = self.deck.get_position(location)
|
||||||
|
if drop_position is None:
|
||||||
|
logger.error(f"未找到丢弃位置 {location} 的信息")
|
||||||
|
return False
|
||||||
|
drop_x, drop_y, drop_z = drop_position
|
||||||
|
|
||||||
|
# 验证丢弃位置的安全性
|
||||||
|
safe_z = drop_z + self.config.safe_height
|
||||||
|
drop_height_z = drop_z + self.config.tip_drop_height
|
||||||
|
|
||||||
|
if not (
|
||||||
|
self._validate_position(drop_x, drop_y, safe_z)
|
||||||
|
and self._validate_position(drop_x, drop_y, drop_height_z)
|
||||||
|
):
|
||||||
|
logger.error("枪头丢弃位置超出安全范围")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if CONTROLLERS_AVAILABLE and self.xyz_controller:
|
||||||
|
# 真实硬件控制流程
|
||||||
|
logger.info("使用真实XYZ控制器进行枪头丢弃")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 移动到丢弃位置上方的安全高度
|
||||||
|
safe_z = drop_z + self.config.tip_drop_height
|
||||||
|
logger.info(f"移动到丢弃位置上方: ({drop_x:.2f}, {drop_y:.2f}, {safe_z:.2f})")
|
||||||
|
move_success = await asyncio.to_thread(
|
||||||
|
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
|
||||||
|
)
|
||||||
|
if not move_success:
|
||||||
|
logger.error("移动到丢弃位置上方失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Z轴下降到丢弃高度
|
||||||
|
logger.info(f"Z轴下降到丢弃高度: {drop_z:.2f}mm")
|
||||||
|
z_down_success = await asyncio.to_thread(
|
||||||
|
self.xyz_controller.move_to_work_coord, drop_x, drop_y, drop_z
|
||||||
|
)
|
||||||
|
if not z_down_success:
|
||||||
|
logger.error("Z轴下降到丢弃位置失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3. 执行枪头弹出动作(如果有移液器控制器)
|
||||||
|
if self.pipette_controller:
|
||||||
|
try:
|
||||||
|
# 发送弹出枪头命令
|
||||||
|
await asyncio.to_thread(self.pipette_controller.eject_tip)
|
||||||
|
logger.info("执行枪头弹出命令")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"枪头弹出命令失败: {e}")
|
||||||
|
|
||||||
|
# 4. 等待一小段时间确保枪头完全脱离
|
||||||
|
await self._ros_node.sleep(0.3)
|
||||||
|
|
||||||
|
# 5. Z轴上升到安全高度
|
||||||
|
logger.info(f"Z轴上升到安全高度: {safe_z:.2f}mm")
|
||||||
|
z_up_success = await asyncio.to_thread(
|
||||||
|
self.xyz_controller.move_to_work_coord, drop_x, drop_y, safe_z
|
||||||
|
)
|
||||||
|
if not z_up_success:
|
||||||
|
logger.error("Z轴上升失败")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 6. 更新当前位置
|
||||||
|
self.current_position = (drop_x, drop_y, safe_z)
|
||||||
|
|
||||||
|
except Exception as drop_error:
|
||||||
|
logger.error(f"枪头丢弃过程中发生错误: {drop_error}")
|
||||||
|
# 尝试移动到安全位置
|
||||||
|
if self.config.emergency_stop_enabled:
|
||||||
|
await self.emergency_stop()
|
||||||
|
await self.move_to_safe_position()
|
||||||
|
return False
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 模拟模式
|
||||||
|
logger.info("模拟模式:执行枪头丢弃动作")
|
||||||
|
await self._ros_node.sleep(0.8) # 模拟整个丢弃过程的时间
|
||||||
|
self.current_position = (drop_x, drop_y, drop_z + self.config.tip_drop_height)
|
||||||
|
|
||||||
|
# 7. 标记枪头已脱离,清空体积
|
||||||
|
self.tip_attached = False
|
||||||
|
self.current_volume = 0.0
|
||||||
|
logger.info("吸头丢弃成功")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"丢弃吸头失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def aspirate(self, volume: float, location: str) -> bool:
|
||||||
|
"""吸取液体"""
|
||||||
|
try:
|
||||||
|
if not self.is_connected:
|
||||||
|
raise LaiYuLiquidError("设备未连接")
|
||||||
|
|
||||||
|
if not self.tip_attached:
|
||||||
|
raise LaiYuLiquidError("没有吸头附着")
|
||||||
|
|
||||||
|
if volume <= 0 or volume > self.config.max_volume:
|
||||||
|
raise LaiYuLiquidError(f"体积超出范围: {volume}")
|
||||||
|
|
||||||
|
# 模拟吸取
|
||||||
|
await self._ros_node.sleep(0.3)
|
||||||
|
self.current_volume += volume
|
||||||
|
logger.debug(f"从 {location} 吸取 {volume} μL")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"吸取失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def dispense(self, volume: float, location: str) -> bool:
|
||||||
|
"""分配液体"""
|
||||||
|
try:
|
||||||
|
if not self.is_connected:
|
||||||
|
raise LaiYuLiquidError("设备未连接")
|
||||||
|
|
||||||
|
if not self.tip_attached:
|
||||||
|
raise LaiYuLiquidError("没有吸头附着")
|
||||||
|
|
||||||
|
if volume <= 0 or volume > self.current_volume:
|
||||||
|
raise LaiYuLiquidError(f"分配体积无效: {volume}")
|
||||||
|
|
||||||
|
# 模拟分配
|
||||||
|
await self._ros_node.sleep(0.3)
|
||||||
|
self.current_volume -= volume
|
||||||
|
logger.debug(f"向 {location} 分配 {volume} μL")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"分配失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuLiquid:
|
||||||
|
"""LaiYu_Liquid 主要接口类"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[LaiYuLiquidConfig] = None, **kwargs):
|
||||||
|
# 如果传入了关键字参数,创建配置对象
|
||||||
|
if kwargs and config is None:
|
||||||
|
# 从kwargs中提取配置参数
|
||||||
|
config_params = {}
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(LaiYuLiquidConfig, key):
|
||||||
|
config_params[key] = value
|
||||||
|
self.config = LaiYuLiquidConfig(**config_params)
|
||||||
|
else:
|
||||||
|
self.config = config or LaiYuLiquidConfig()
|
||||||
|
|
||||||
|
# 先创建deck,然后传递给backend
|
||||||
|
self.deck = LaiYuLiquidDeck(self.config)
|
||||||
|
self.backend = LaiYuLiquidBackend(self.config, self.deck)
|
||||||
|
self.is_setup = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_position(self) -> Tuple[float, float, float]:
|
||||||
|
"""获取当前位置"""
|
||||||
|
return self.backend.current_position
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_volume(self) -> float:
|
||||||
|
"""获取当前体积"""
|
||||||
|
return self.backend.current_volume
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""获取连接状态"""
|
||||||
|
return self.backend.is_connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_initialized(self) -> bool:
|
||||||
|
"""获取初始化状态"""
|
||||||
|
return self.backend.is_initialized
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tip_attached(self) -> bool:
|
||||||
|
"""获取吸头附着状态"""
|
||||||
|
return self.backend.tip_attached
|
||||||
|
|
||||||
|
async def setup(self) -> bool:
|
||||||
|
"""设置液体处理器"""
|
||||||
|
try:
|
||||||
|
success = await self.backend.setup()
|
||||||
|
if success:
|
||||||
|
self.is_setup = True
|
||||||
|
logger.info("LaiYu_Liquid 设置完成")
|
||||||
|
return success
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LaiYu_Liquid 设置失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""停止液体处理器"""
|
||||||
|
await self.backend.stop()
|
||||||
|
self.is_setup = False
|
||||||
|
|
||||||
|
async def transfer(
|
||||||
|
self, source: str, target: str, volume: float, tip_rack: str = "tip_rack_1", tip_position: int = 0
|
||||||
|
) -> bool:
|
||||||
|
"""液体转移"""
|
||||||
|
try:
|
||||||
|
if not self.is_setup:
|
||||||
|
raise LaiYuLiquidError("设备未设置")
|
||||||
|
|
||||||
|
# 获取源和目标位置
|
||||||
|
source_pos = self.deck.get_position(source)
|
||||||
|
target_pos = self.deck.get_position(target)
|
||||||
|
tip_pos = self.deck.get_position(tip_rack)
|
||||||
|
|
||||||
|
if not all([source_pos, target_pos, tip_pos]):
|
||||||
|
raise LaiYuLiquidError("位置信息不完整")
|
||||||
|
|
||||||
|
# 执行转移步骤
|
||||||
|
steps = [
|
||||||
|
("移动到吸头架", self.backend.move_to(*tip_pos)),
|
||||||
|
("拾取吸头", self.backend.pick_up_tip(tip_rack, tip_position)),
|
||||||
|
("移动到源位置", self.backend.move_to(*source_pos)),
|
||||||
|
("吸取液体", self.backend.aspirate(volume, source)),
|
||||||
|
("移动到目标位置", self.backend.move_to(*target_pos)),
|
||||||
|
("分配液体", self.backend.dispense(volume, target)),
|
||||||
|
("丢弃吸头", self.backend.drop_tip()),
|
||||||
|
]
|
||||||
|
|
||||||
|
for step_name, step_coro in steps:
|
||||||
|
logger.debug(f"执行步骤: {step_name}")
|
||||||
|
success = await step_coro
|
||||||
|
if not success:
|
||||||
|
raise LaiYuLiquidError(f"步骤失败: {step_name}")
|
||||||
|
|
||||||
|
logger.info(f"液体转移完成: {source} -> {target}, {volume} μL")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"液体转移失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def add_resource(self, name: str, resource_type: str, position: Tuple[float, float, float]):
|
||||||
|
"""添加资源到工作台"""
|
||||||
|
if resource_type == "plate":
|
||||||
|
resource = Plate(name)
|
||||||
|
elif resource_type == "tip_rack":
|
||||||
|
resource = TipRack(name)
|
||||||
|
else:
|
||||||
|
resource = Resource(name)
|
||||||
|
|
||||||
|
self.deck.add_resource(name, resource, position)
|
||||||
|
|
||||||
|
def get_status(self) -> Dict[str, Any]:
|
||||||
|
"""获取设备状态"""
|
||||||
|
return {
|
||||||
|
"connected": self.backend.is_connected,
|
||||||
|
"setup": self.is_setup,
|
||||||
|
"current_position": self.backend.current_position,
|
||||||
|
"tip_attached": self.backend.tip_attached,
|
||||||
|
"current_volume": self.backend.current_volume,
|
||||||
|
"resources": self.deck.list_resources(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_quick_setup() -> LaiYuLiquidDeck:
|
||||||
|
"""
|
||||||
|
创建快速设置的LaiYu液体处理工作站
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuLiquidDeck: 配置好的工作台实例
|
||||||
|
"""
|
||||||
|
# 创建默认配置
|
||||||
|
config = LaiYuLiquidConfig()
|
||||||
|
|
||||||
|
# 创建工作台
|
||||||
|
deck = LaiYuLiquidDeck(config)
|
||||||
|
|
||||||
|
# 导入资源创建函数
|
||||||
|
try:
|
||||||
|
from .laiyu_liquid_res import (
|
||||||
|
create_tip_rack_1000ul,
|
||||||
|
create_tip_rack_200ul,
|
||||||
|
create_96_well_plate,
|
||||||
|
create_waste_container,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加基本资源
|
||||||
|
tip_rack_1000 = create_tip_rack_1000ul("tip_rack_1000")
|
||||||
|
tip_rack_200 = create_tip_rack_200ul("tip_rack_200")
|
||||||
|
plate_96 = create_96_well_plate("plate_96")
|
||||||
|
waste = create_waste_container("waste")
|
||||||
|
|
||||||
|
# 添加到工作台
|
||||||
|
deck.add_resource("tip_rack_1000", tip_rack_1000, (50, 50, 0))
|
||||||
|
deck.add_resource("tip_rack_200", tip_rack_200, (150, 50, 0))
|
||||||
|
deck.add_resource("plate_96", plate_96, (250, 50, 0))
|
||||||
|
deck.add_resource("waste", waste, (50, 150, 0))
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# 如果资源模块不可用,创建空的工作台
|
||||||
|
logger.warning("资源模块不可用,创建空的工作台")
|
||||||
|
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LaiYuLiquid",
|
||||||
|
"LaiYuLiquidBackend",
|
||||||
|
"LaiYuLiquidConfig",
|
||||||
|
"LaiYuLiquidDeck",
|
||||||
|
"LaiYuLiquidContainer",
|
||||||
|
"LaiYuLiquidTipRack",
|
||||||
|
"LaiYuLiquidError",
|
||||||
|
"create_quick_setup",
|
||||||
|
"get_module_info",
|
||||||
|
]
|
||||||
954
unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py
Normal file
954
unilabos/devices/laiyu_liquid/core/laiyu_liquid_res.py
Normal file
@@ -0,0 +1,954 @@
|
|||||||
|
"""
|
||||||
|
LaiYu_Liquid 资源定义模块
|
||||||
|
|
||||||
|
该模块提供了 LaiYu_Liquid 工作站专用的资源定义函数,包括:
|
||||||
|
- 各种规格的枪头架
|
||||||
|
- 不同类型的板和容器
|
||||||
|
- 特殊功能位置
|
||||||
|
- 资源创建的便捷函数
|
||||||
|
|
||||||
|
所有资源都基于 deck.json 中的配置参数创建。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Dict, List, Optional, Tuple, Any
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# PyLabRobot 资源导入
|
||||||
|
try:
|
||||||
|
from pylabrobot.resources import (
|
||||||
|
Resource, Deck, Plate, TipRack, Container, Tip,
|
||||||
|
Coordinate
|
||||||
|
)
|
||||||
|
from pylabrobot.resources.tip_rack import TipSpot
|
||||||
|
from pylabrobot.resources.well import Well as PlateWell
|
||||||
|
PYLABROBOT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
# 如果 PyLabRobot 不可用,创建模拟类
|
||||||
|
PYLABROBOT_AVAILABLE = False
|
||||||
|
|
||||||
|
class Resource:
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
class Deck(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Plate(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TipRack(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Container(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Tip(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TipSpot(Resource):
|
||||||
|
def __init__(self, name: str, **kwargs):
|
||||||
|
super().__init__(name)
|
||||||
|
# 忽略其他参数
|
||||||
|
|
||||||
|
class PlateWell(Resource):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Coordinate:
|
||||||
|
def __init__(self, x: float, y: float, z: float):
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
self.z = z
|
||||||
|
|
||||||
|
# 本地导入
|
||||||
|
from .laiyu_liquid_main import LaiYuLiquidDeck, LaiYuLiquidContainer, LaiYuLiquidTipRack
|
||||||
|
|
||||||
|
|
||||||
|
def load_deck_config() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
加载工作台配置文件
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, Any]: 配置字典
|
||||||
|
"""
|
||||||
|
# 优先使用最新的deckconfig.json文件
|
||||||
|
config_path = Path(__file__).parent / "controllers" / "deckconfig.json"
|
||||||
|
|
||||||
|
# 如果最新配置文件不存在,回退到旧配置文件
|
||||||
|
if not config_path.exists():
|
||||||
|
config_path = Path(__file__).parent / "config" / "deck.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
# 如果找不到配置文件,返回默认配置
|
||||||
|
return {
|
||||||
|
"name": "LaiYu_Liquid_Deck",
|
||||||
|
"size_x": 340.0,
|
||||||
|
"size_y": 250.0,
|
||||||
|
"size_z": 160.0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 加载配置
|
||||||
|
DECK_CONFIG = load_deck_config()
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuTipRack1000(LaiYuLiquidTipRack):
|
||||||
|
"""1000μL 枪头架"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""
|
||||||
|
初始化1000μL枪头架
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 枪头架名称
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=127.76,
|
||||||
|
size_y=85.48,
|
||||||
|
size_z=30.0,
|
||||||
|
tip_count=96,
|
||||||
|
tip_volume=1000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建枪头位置
|
||||||
|
self._create_tip_spots(
|
||||||
|
tip_count=96,
|
||||||
|
tip_spacing=9.0,
|
||||||
|
tip_type="1000ul"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||||
|
"""
|
||||||
|
创建枪头位置 - 从配置文件中读取绝对坐标
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tip_count: 枪头数量
|
||||||
|
tip_spacing: 枪头间距
|
||||||
|
tip_type: 枪头类型
|
||||||
|
"""
|
||||||
|
# 从配置文件中获取枪头架的孔位信息
|
||||||
|
config = DECK_CONFIG
|
||||||
|
tip_module = None
|
||||||
|
|
||||||
|
# 查找枪头架模块
|
||||||
|
for module in config.get("children", []):
|
||||||
|
if module.get("type") == "tip_rack":
|
||||||
|
tip_module = module
|
||||||
|
break
|
||||||
|
|
||||||
|
if not tip_module:
|
||||||
|
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||||
|
rows = 8
|
||||||
|
cols = 12
|
||||||
|
|
||||||
|
for row in range(rows):
|
||||||
|
for col in range(cols):
|
||||||
|
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||||
|
x = col * tip_spacing + tip_spacing / 2
|
||||||
|
y = row * tip_spacing + tip_spacing / 2
|
||||||
|
|
||||||
|
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||||
|
if PYLABROBOT_AVAILABLE:
|
||||||
|
# PyLabRobot的Tip需要特定参数
|
||||||
|
tip = Tip(
|
||||||
|
has_filter=False,
|
||||||
|
total_tip_length=95.0, # 1000ul枪头长度
|
||||||
|
maximal_volume=1000.0, # 最大体积
|
||||||
|
fitting_depth=8.0 # 安装深度
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 模拟类只需要name
|
||||||
|
tip = Tip(name=f"tip_{spot_name}")
|
||||||
|
|
||||||
|
# 创建枪头位置
|
||||||
|
if PYLABROBOT_AVAILABLE:
|
||||||
|
# PyLabRobot的TipSpot需要特定参数
|
||||||
|
tip_spot = TipSpot(
|
||||||
|
name=spot_name,
|
||||||
|
size_x=9.0, # 枪头位置宽度
|
||||||
|
size_y=9.0, # 枪头位置深度
|
||||||
|
size_z=95.0, # 枪头位置高度
|
||||||
|
make_tip=lambda: tip # 创建枪头的函数
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 模拟类只需要name
|
||||||
|
tip_spot = TipSpot(name=spot_name)
|
||||||
|
|
||||||
|
# 将吸头位置分配到吸头架
|
||||||
|
self.assign_child_resource(
|
||||||
|
tip_spot,
|
||||||
|
location=Coordinate(x, y, 0)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 使用配置文件中的绝对坐标
|
||||||
|
module_position = tip_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||||
|
|
||||||
|
for well_config in tip_module.get("wells", []):
|
||||||
|
spot_name = well_config["id"]
|
||||||
|
well_pos = well_config["position"]
|
||||||
|
|
||||||
|
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||||
|
relative_x = well_pos["x"] - module_position["x"]
|
||||||
|
relative_y = well_pos["y"] - module_position["y"]
|
||||||
|
relative_z = well_pos["z"] - module_position["z"]
|
||||||
|
|
||||||
|
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||||
|
if PYLABROBOT_AVAILABLE:
|
||||||
|
# PyLabRobot的Tip需要特定参数
|
||||||
|
tip = Tip(
|
||||||
|
has_filter=False,
|
||||||
|
total_tip_length=95.0, # 1000ul枪头长度
|
||||||
|
maximal_volume=1000.0, # 最大体积
|
||||||
|
fitting_depth=8.0 # 安装深度
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 模拟类只需要name
|
||||||
|
tip = Tip(name=f"tip_{spot_name}")
|
||||||
|
|
||||||
|
# 创建枪头位置
|
||||||
|
if PYLABROBOT_AVAILABLE:
|
||||||
|
# PyLabRobot的TipSpot需要特定参数
|
||||||
|
tip_spot = TipSpot(
|
||||||
|
name=spot_name,
|
||||||
|
size_x=well_config.get("diameter", 9.0), # 使用配置中的直径
|
||||||
|
size_y=well_config.get("diameter", 9.0),
|
||||||
|
size_z=well_config.get("depth", 95.0), # 使用配置中的深度
|
||||||
|
make_tip=lambda: tip # 创建枪头的函数
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 模拟类只需要name
|
||||||
|
tip_spot = TipSpot(name=spot_name)
|
||||||
|
|
||||||
|
# 将吸头位置分配到吸头架
|
||||||
|
self.assign_child_resource(
|
||||||
|
tip_spot,
|
||||||
|
location=Coordinate(relative_x, relative_y, relative_z)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||||
|
# TipSpot的make_tip函数会在需要时创建Tip
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuTipRack200(LaiYuLiquidTipRack):
|
||||||
|
"""200μL 枪头架"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""
|
||||||
|
初始化200μL枪头架
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 枪头架名称
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=127.76,
|
||||||
|
size_y=85.48,
|
||||||
|
size_z=30.0,
|
||||||
|
tip_count=96,
|
||||||
|
tip_volume=200.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建枪头位置
|
||||||
|
self._create_tip_spots(
|
||||||
|
tip_count=96,
|
||||||
|
tip_spacing=9.0,
|
||||||
|
tip_type="200ul"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_tip_spots(self, tip_count: int, tip_spacing: float, tip_type: str):
|
||||||
|
"""
|
||||||
|
创建枪头位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tip_count: 枪头数量
|
||||||
|
tip_spacing: 枪头间距
|
||||||
|
tip_type: 枪头类型
|
||||||
|
"""
|
||||||
|
rows = 8
|
||||||
|
cols = 12
|
||||||
|
|
||||||
|
for row in range(rows):
|
||||||
|
for col in range(cols):
|
||||||
|
spot_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||||
|
x = col * tip_spacing + tip_spacing / 2
|
||||||
|
y = row * tip_spacing + tip_spacing / 2
|
||||||
|
|
||||||
|
# 创建枪头 - 根据PyLabRobot或模拟类使用不同参数
|
||||||
|
if PYLABROBOT_AVAILABLE:
|
||||||
|
# PyLabRobot的Tip需要特定参数
|
||||||
|
tip = Tip(
|
||||||
|
has_filter=False,
|
||||||
|
total_tip_length=72.0, # 200ul枪头长度
|
||||||
|
maximal_volume=200.0, # 最大体积
|
||||||
|
fitting_depth=8.0 # 安装深度
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 模拟类只需要name
|
||||||
|
tip = Tip(name=f"tip_{spot_name}")
|
||||||
|
|
||||||
|
# 创建枪头位置
|
||||||
|
if PYLABROBOT_AVAILABLE:
|
||||||
|
# PyLabRobot的TipSpot需要特定参数
|
||||||
|
tip_spot = TipSpot(
|
||||||
|
name=spot_name,
|
||||||
|
size_x=9.0, # 枪头位置宽度
|
||||||
|
size_y=9.0, # 枪头位置深度
|
||||||
|
size_z=72.0, # 枪头位置高度
|
||||||
|
make_tip=lambda: tip # 创建枪头的函数
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 模拟类只需要name
|
||||||
|
tip_spot = TipSpot(name=spot_name)
|
||||||
|
|
||||||
|
# 将吸头位置分配到吸头架
|
||||||
|
self.assign_child_resource(
|
||||||
|
tip_spot,
|
||||||
|
location=Coordinate(x, y, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注意:在PyLabRobot中,Tip不是Resource,不需要分配给TipSpot
|
||||||
|
# TipSpot的make_tip函数会在需要时创建Tip
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYu96WellPlate(LaiYuLiquidContainer):
|
||||||
|
"""96孔板"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, lid_height: float = 0.0):
|
||||||
|
"""
|
||||||
|
初始化96孔板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 板名称
|
||||||
|
lid_height: 盖子高度
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=127.76,
|
||||||
|
size_y=85.48,
|
||||||
|
size_z=14.22,
|
||||||
|
container_type="96_well_plate",
|
||||||
|
volume=0.0,
|
||||||
|
max_volume=200.0,
|
||||||
|
lid_height=lid_height
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
self._create_wells(
|
||||||
|
well_count=96,
|
||||||
|
well_volume=200.0,
|
||||||
|
well_spacing=9.0
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_size_z(self) -> float:
|
||||||
|
"""获取孔位深度"""
|
||||||
|
return 10.0 # 96孔板孔位深度
|
||||||
|
|
||||||
|
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||||
|
"""
|
||||||
|
创建孔位 - 从配置文件中读取绝对坐标
|
||||||
|
|
||||||
|
Args:
|
||||||
|
well_count: 孔位数量
|
||||||
|
well_volume: 孔位体积
|
||||||
|
well_spacing: 孔位间距
|
||||||
|
"""
|
||||||
|
# 从配置文件中获取96孔板的孔位信息
|
||||||
|
config = DECK_CONFIG
|
||||||
|
plate_module = None
|
||||||
|
|
||||||
|
# 查找96孔板模块
|
||||||
|
for module in config.get("children", []):
|
||||||
|
if module.get("type") == "96_well_plate":
|
||||||
|
plate_module = module
|
||||||
|
break
|
||||||
|
|
||||||
|
if not plate_module:
|
||||||
|
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||||
|
rows = 8
|
||||||
|
cols = 12
|
||||||
|
|
||||||
|
for row in range(rows):
|
||||||
|
for col in range(cols):
|
||||||
|
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||||
|
x = col * well_spacing + well_spacing / 2
|
||||||
|
y = row * well_spacing + well_spacing / 2
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
well = PlateWell(
|
||||||
|
name=well_name,
|
||||||
|
size_x=well_spacing * 0.8,
|
||||||
|
size_y=well_spacing * 0.8,
|
||||||
|
size_z=self.get_size_z(),
|
||||||
|
max_volume=well_volume
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加到板
|
||||||
|
self.assign_child_resource(
|
||||||
|
well,
|
||||||
|
location=Coordinate(x, y, 0)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 使用配置文件中的绝对坐标
|
||||||
|
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||||
|
|
||||||
|
for well_config in plate_module.get("wells", []):
|
||||||
|
well_name = well_config["id"]
|
||||||
|
well_pos = well_config["position"]
|
||||||
|
|
||||||
|
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||||
|
relative_x = well_pos["x"] - module_position["x"]
|
||||||
|
relative_y = well_pos["y"] - module_position["y"]
|
||||||
|
relative_z = well_pos["z"] - module_position["z"]
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
well = PlateWell(
|
||||||
|
name=well_name,
|
||||||
|
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||||
|
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||||
|
size_z=well_config.get("depth", self.get_size_z()),
|
||||||
|
max_volume=well_config.get("volume", well_volume)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加到板
|
||||||
|
self.assign_child_resource(
|
||||||
|
well,
|
||||||
|
location=Coordinate(relative_x, relative_y, relative_z)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuDeepWellPlate(LaiYuLiquidContainer):
|
||||||
|
"""深孔板"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, lid_height: float = 0.0):
|
||||||
|
"""
|
||||||
|
初始化深孔板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 板名称
|
||||||
|
lid_height: 盖子高度
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=127.76,
|
||||||
|
size_y=85.48,
|
||||||
|
size_z=41.3,
|
||||||
|
container_type="deep_well_plate",
|
||||||
|
volume=0.0,
|
||||||
|
max_volume=2000.0,
|
||||||
|
lid_height=lid_height
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
self._create_wells(
|
||||||
|
well_count=96,
|
||||||
|
well_volume=2000.0,
|
||||||
|
well_spacing=9.0
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_size_z(self) -> float:
|
||||||
|
"""获取孔位深度"""
|
||||||
|
return 35.0 # 深孔板孔位深度
|
||||||
|
|
||||||
|
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||||
|
"""
|
||||||
|
创建孔位 - 从配置文件中读取绝对坐标
|
||||||
|
|
||||||
|
Args:
|
||||||
|
well_count: 孔位数量
|
||||||
|
well_volume: 孔位体积
|
||||||
|
well_spacing: 孔位间距
|
||||||
|
"""
|
||||||
|
# 从配置文件中获取深孔板的孔位信息
|
||||||
|
config = DECK_CONFIG
|
||||||
|
plate_module = None
|
||||||
|
|
||||||
|
# 查找深孔板模块(通常是第二个96孔板模块)
|
||||||
|
plate_modules = []
|
||||||
|
for module in config.get("children", []):
|
||||||
|
if module.get("type") == "96_well_plate":
|
||||||
|
plate_modules.append(module)
|
||||||
|
|
||||||
|
# 如果有多个96孔板模块,选择第二个作为深孔板
|
||||||
|
if len(plate_modules) > 1:
|
||||||
|
plate_module = plate_modules[1]
|
||||||
|
elif len(plate_modules) == 1:
|
||||||
|
plate_module = plate_modules[0]
|
||||||
|
|
||||||
|
if not plate_module:
|
||||||
|
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||||
|
rows = 8
|
||||||
|
cols = 12
|
||||||
|
|
||||||
|
for row in range(rows):
|
||||||
|
for col in range(cols):
|
||||||
|
well_name = f"{chr(65 + row)}{col + 1:02d}"
|
||||||
|
x = col * well_spacing + well_spacing / 2
|
||||||
|
y = row * well_spacing + well_spacing / 2
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
well = PlateWell(
|
||||||
|
name=well_name,
|
||||||
|
size_x=well_spacing * 0.8,
|
||||||
|
size_y=well_spacing * 0.8,
|
||||||
|
size_z=self.get_size_z(),
|
||||||
|
max_volume=well_volume
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加到板
|
||||||
|
self.assign_child_resource(
|
||||||
|
well,
|
||||||
|
location=Coordinate(x, y, 0)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 使用配置文件中的绝对坐标
|
||||||
|
module_position = plate_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||||
|
|
||||||
|
for well_config in plate_module.get("wells", []):
|
||||||
|
well_name = well_config["id"]
|
||||||
|
well_pos = well_config["position"]
|
||||||
|
|
||||||
|
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||||
|
relative_x = well_pos["x"] - module_position["x"]
|
||||||
|
relative_y = well_pos["y"] - module_position["y"]
|
||||||
|
relative_z = well_pos["z"] - module_position["z"]
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
well = PlateWell(
|
||||||
|
name=well_name,
|
||||||
|
size_x=well_config.get("diameter", 8.2) * 0.8, # 使用配置中的直径
|
||||||
|
size_y=well_config.get("diameter", 8.2) * 0.8,
|
||||||
|
size_z=well_config.get("depth", self.get_size_z()),
|
||||||
|
max_volume=well_config.get("volume", well_volume)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加到板
|
||||||
|
self.assign_child_resource(
|
||||||
|
well,
|
||||||
|
location=Coordinate(relative_x, relative_y, relative_z)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuWasteContainer(Container):
|
||||||
|
"""废液容器"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""
|
||||||
|
初始化废液容器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 容器名称
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=100.0,
|
||||||
|
size_y=100.0,
|
||||||
|
size_z=50.0,
|
||||||
|
max_volume=5000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuWashContainer(Container):
|
||||||
|
"""清洗容器"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""
|
||||||
|
初始化清洗容器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 容器名称
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=100.0,
|
||||||
|
size_y=100.0,
|
||||||
|
size_z=50.0,
|
||||||
|
max_volume=5000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuReagentContainer(Container):
|
||||||
|
"""试剂容器"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""
|
||||||
|
初始化试剂容器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 容器名称
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=50.0,
|
||||||
|
size_y=50.0,
|
||||||
|
size_z=100.0,
|
||||||
|
max_volume=2000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYu8TubeRack(LaiYuLiquidContainer):
|
||||||
|
"""8管试管架"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""
|
||||||
|
初始化8管试管架
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 试管架名称
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=151.0,
|
||||||
|
size_y=75.0,
|
||||||
|
size_z=75.0,
|
||||||
|
container_type="tube_rack",
|
||||||
|
volume=0.0,
|
||||||
|
max_volume=77000.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
self._create_wells(
|
||||||
|
well_count=8,
|
||||||
|
well_volume=77000.0,
|
||||||
|
well_spacing=35.0
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_size_z(self) -> float:
|
||||||
|
"""获取孔位深度"""
|
||||||
|
return 117.0 # 试管深度
|
||||||
|
|
||||||
|
def _create_wells(self, well_count: int, well_volume: float, well_spacing: float):
|
||||||
|
"""
|
||||||
|
创建孔位 - 从配置文件中读取绝对坐标
|
||||||
|
|
||||||
|
Args:
|
||||||
|
well_count: 孔位数量
|
||||||
|
well_volume: 孔位体积
|
||||||
|
well_spacing: 孔位间距
|
||||||
|
"""
|
||||||
|
# 从配置文件中获取8管试管架的孔位信息
|
||||||
|
config = DECK_CONFIG
|
||||||
|
tube_module = None
|
||||||
|
|
||||||
|
# 查找8管试管架模块
|
||||||
|
for module in config.get("children", []):
|
||||||
|
if module.get("type") == "tube_rack":
|
||||||
|
tube_module = module
|
||||||
|
break
|
||||||
|
|
||||||
|
if not tube_module:
|
||||||
|
# 如果配置文件中没有找到,使用默认的相对坐标计算
|
||||||
|
rows = 2
|
||||||
|
cols = 4
|
||||||
|
|
||||||
|
for row in range(rows):
|
||||||
|
for col in range(cols):
|
||||||
|
well_name = f"{chr(65 + row)}{col + 1}"
|
||||||
|
x = col * well_spacing + well_spacing / 2
|
||||||
|
y = row * well_spacing + well_spacing / 2
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
well = PlateWell(
|
||||||
|
name=well_name,
|
||||||
|
size_x=29.0,
|
||||||
|
size_y=29.0,
|
||||||
|
size_z=self.get_size_z(),
|
||||||
|
max_volume=well_volume
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加到试管架
|
||||||
|
self.assign_child_resource(
|
||||||
|
well,
|
||||||
|
location=Coordinate(x, y, 0)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 使用配置文件中的绝对坐标
|
||||||
|
module_position = tube_module.get("position", {"x": 0, "y": 0, "z": 0})
|
||||||
|
|
||||||
|
for well_config in tube_module.get("wells", []):
|
||||||
|
well_name = well_config["id"]
|
||||||
|
well_pos = well_config["position"]
|
||||||
|
|
||||||
|
# 计算相对于模块的坐标(绝对坐标减去模块位置)
|
||||||
|
relative_x = well_pos["x"] - module_position["x"]
|
||||||
|
relative_y = well_pos["y"] - module_position["y"]
|
||||||
|
relative_z = well_pos["z"] - module_position["z"]
|
||||||
|
|
||||||
|
# 创建孔位
|
||||||
|
well = PlateWell(
|
||||||
|
name=well_name,
|
||||||
|
size_x=well_config.get("diameter", 29.0),
|
||||||
|
size_y=well_config.get("diameter", 29.0),
|
||||||
|
size_z=well_config.get("depth", self.get_size_z()),
|
||||||
|
max_volume=well_config.get("volume", well_volume)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加到试管架
|
||||||
|
self.assign_child_resource(
|
||||||
|
well,
|
||||||
|
location=Coordinate(relative_x, relative_y, relative_z)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuTipDisposal(Resource):
|
||||||
|
"""枪头废料位置"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""
|
||||||
|
初始化枪头废料位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 位置名称
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=100.0,
|
||||||
|
size_y=100.0,
|
||||||
|
size_z=50.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LaiYuMaintenancePosition(Resource):
|
||||||
|
"""维护位置"""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
"""
|
||||||
|
初始化维护位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 位置名称
|
||||||
|
"""
|
||||||
|
super().__init__(
|
||||||
|
name=name,
|
||||||
|
size_x=50.0,
|
||||||
|
size_y=50.0,
|
||||||
|
size_z=100.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 资源创建函数
|
||||||
|
def create_tip_rack_1000ul(name: str = "tip_rack_1000ul") -> LaiYuTipRack1000:
|
||||||
|
"""
|
||||||
|
创建1000μL枪头架
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 枪头架名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuTipRack1000: 1000μL枪头架实例
|
||||||
|
"""
|
||||||
|
return LaiYuTipRack1000(name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tip_rack_200ul(name: str = "tip_rack_200ul") -> LaiYuTipRack200:
|
||||||
|
"""
|
||||||
|
创建200μL枪头架
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 枪头架名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuTipRack200: 200μL枪头架实例
|
||||||
|
"""
|
||||||
|
return LaiYuTipRack200(name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_96_well_plate(name: str = "96_well_plate", lid_height: float = 0.0) -> LaiYu96WellPlate:
|
||||||
|
"""
|
||||||
|
创建96孔板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 板名称
|
||||||
|
lid_height: 盖子高度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYu96WellPlate: 96孔板实例
|
||||||
|
"""
|
||||||
|
return LaiYu96WellPlate(name, lid_height)
|
||||||
|
|
||||||
|
|
||||||
|
def create_deep_well_plate(name: str = "deep_well_plate", lid_height: float = 0.0) -> LaiYuDeepWellPlate:
|
||||||
|
"""
|
||||||
|
创建深孔板
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 板名称
|
||||||
|
lid_height: 盖子高度
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuDeepWellPlate: 深孔板实例
|
||||||
|
"""
|
||||||
|
return LaiYuDeepWellPlate(name, lid_height)
|
||||||
|
|
||||||
|
|
||||||
|
def create_8_tube_rack(name: str = "8_tube_rack") -> LaiYu8TubeRack:
|
||||||
|
"""
|
||||||
|
创建8管试管架
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 试管架名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYu8TubeRack: 8管试管架实例
|
||||||
|
"""
|
||||||
|
return LaiYu8TubeRack(name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_waste_container(name: str = "waste_container") -> LaiYuWasteContainer:
|
||||||
|
"""
|
||||||
|
创建废液容器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 容器名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuWasteContainer: 废液容器实例
|
||||||
|
"""
|
||||||
|
return LaiYuWasteContainer(name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_wash_container(name: str = "wash_container") -> LaiYuWashContainer:
|
||||||
|
"""
|
||||||
|
创建清洗容器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 容器名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuWashContainer: 清洗容器实例
|
||||||
|
"""
|
||||||
|
return LaiYuWashContainer(name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_reagent_container(name: str = "reagent_container") -> LaiYuReagentContainer:
|
||||||
|
"""
|
||||||
|
创建试剂容器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 容器名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuReagentContainer: 试剂容器实例
|
||||||
|
"""
|
||||||
|
return LaiYuReagentContainer(name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_tip_disposal(name: str = "tip_disposal") -> LaiYuTipDisposal:
|
||||||
|
"""
|
||||||
|
创建枪头废料位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 位置名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuTipDisposal: 枪头废料位置实例
|
||||||
|
"""
|
||||||
|
return LaiYuTipDisposal(name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_maintenance_position(name: str = "maintenance_position") -> LaiYuMaintenancePosition:
|
||||||
|
"""
|
||||||
|
创建维护位置
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 位置名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuMaintenancePosition: 维护位置实例
|
||||||
|
"""
|
||||||
|
return LaiYuMaintenancePosition(name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_standard_deck() -> LaiYuLiquidDeck:
|
||||||
|
"""
|
||||||
|
创建标准工作台配置
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LaiYuLiquidDeck: 配置好的工作台实例
|
||||||
|
"""
|
||||||
|
# 从配置文件创建工作台
|
||||||
|
deck = LaiYuLiquidDeck(config=DECK_CONFIG)
|
||||||
|
|
||||||
|
return deck
|
||||||
|
|
||||||
|
|
||||||
|
def get_resource_by_name(deck: LaiYuLiquidDeck, name: str) -> Optional[Resource]:
|
||||||
|
"""
|
||||||
|
根据名称获取资源
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck: 工作台实例
|
||||||
|
name: 资源名称
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Resource]: 找到的资源,如果不存在则返回None
|
||||||
|
"""
|
||||||
|
for child in deck.children:
|
||||||
|
if child.name == name:
|
||||||
|
return child
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_resources_by_type(deck: LaiYuLiquidDeck, resource_type: type) -> List[Resource]:
|
||||||
|
"""
|
||||||
|
根据类型获取资源列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck: 工作台实例
|
||||||
|
resource_type: 资源类型
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[Resource]: 匹配类型的资源列表
|
||||||
|
"""
|
||||||
|
return [child for child in deck.children if isinstance(child, resource_type)]
|
||||||
|
|
||||||
|
|
||||||
|
def list_all_resources(deck: LaiYuLiquidDeck) -> Dict[str, List[str]]:
|
||||||
|
"""
|
||||||
|
列出所有资源
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck: 工作台实例
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[str, List[str]]: 按类型分组的资源名称字典
|
||||||
|
"""
|
||||||
|
resources = {
|
||||||
|
"tip_racks": [],
|
||||||
|
"plates": [],
|
||||||
|
"containers": [],
|
||||||
|
"positions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for child in deck.children:
|
||||||
|
if isinstance(child, (LaiYuTipRack1000, LaiYuTipRack200)):
|
||||||
|
resources["tip_racks"].append(child.name)
|
||||||
|
elif isinstance(child, (LaiYu96WellPlate, LaiYuDeepWellPlate)):
|
||||||
|
resources["plates"].append(child.name)
|
||||||
|
elif isinstance(child, (LaiYuWasteContainer, LaiYuWashContainer, LaiYuReagentContainer)):
|
||||||
|
resources["containers"].append(child.name)
|
||||||
|
elif isinstance(child, (LaiYuTipDisposal, LaiYuMaintenancePosition)):
|
||||||
|
resources["positions"].append(child.name)
|
||||||
|
|
||||||
|
return resources
|
||||||
|
|
||||||
|
|
||||||
|
# 导出的类别名(向后兼容)
|
||||||
|
TipRack1000ul = LaiYuTipRack1000
|
||||||
|
TipRack200ul = LaiYuTipRack200
|
||||||
|
Plate96Well = LaiYu96WellPlate
|
||||||
|
Plate96DeepWell = LaiYuDeepWellPlate
|
||||||
|
TubeRack8 = LaiYu8TubeRack
|
||||||
|
WasteContainer = LaiYuWasteContainer
|
||||||
|
WashContainer = LaiYuWashContainer
|
||||||
|
ReagentContainer = LaiYuReagentContainer
|
||||||
|
TipDisposal = LaiYuTipDisposal
|
||||||
|
MaintenancePosition = LaiYuMaintenancePosition
|
||||||
69
unilabos/devices/laiyu_liquid/docs/CHANGELOG.md
Normal file
69
unilabos/devices/laiyu_liquid/docs/CHANGELOG.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 更新日志
|
||||||
|
|
||||||
|
本文档记录了 LaiYu_Liquid 模块的所有重要变更。
|
||||||
|
|
||||||
|
## [1.0.0] - 2024-01-XX
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
- ✅ 完整的液体处理工作站集成
|
||||||
|
- ✅ RS485 通信协议支持
|
||||||
|
- ✅ SOPA 气动式移液器驱动
|
||||||
|
- ✅ XYZ 三轴步进电机控制
|
||||||
|
- ✅ PyLabRobot 兼容后端
|
||||||
|
- ✅ 标准化资源管理系统
|
||||||
|
- ✅ 96孔板、离心管架、枪头架支持
|
||||||
|
- ✅ RViz 可视化后端
|
||||||
|
- ✅ 完整的配置管理系统
|
||||||
|
- ✅ 抽象协议实现
|
||||||
|
- ✅ 生产级错误处理和日志记录
|
||||||
|
|
||||||
|
### 技术特性
|
||||||
|
- **硬件支持**: SOPA移液器 + XYZ三轴运动平台
|
||||||
|
- **通信协议**: RS485总线,波特率115200
|
||||||
|
- **坐标系统**: 机械坐标与工作坐标自动转换
|
||||||
|
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||||
|
- **兼容性**: 完全兼容 PyLabRobot 框架
|
||||||
|
|
||||||
|
### 文件结构
|
||||||
|
```
|
||||||
|
LaiYu_Liquid/
|
||||||
|
├── core/
|
||||||
|
│ └── LaiYu_Liquid.py # 主模块文件
|
||||||
|
├── __init__.py # 模块初始化
|
||||||
|
├── abstract_protocol.py # 抽象协议
|
||||||
|
├── laiyu_liquid_res.py # 资源管理
|
||||||
|
├── rviz_backend.py # RViz后端
|
||||||
|
├── backend/ # 后端驱动
|
||||||
|
├── config/ # 配置文件
|
||||||
|
├── controllers/ # 控制器
|
||||||
|
├── docs/ # 技术文档
|
||||||
|
└── drivers/ # 底层驱动
|
||||||
|
```
|
||||||
|
|
||||||
|
### 已知问题
|
||||||
|
- 无
|
||||||
|
|
||||||
|
### 依赖要求
|
||||||
|
- Python 3.8+
|
||||||
|
- PyLabRobot
|
||||||
|
- pyserial
|
||||||
|
- asyncio
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本说明
|
||||||
|
|
||||||
|
### 版本号格式
|
||||||
|
采用语义化版本控制 (Semantic Versioning): `MAJOR.MINOR.PATCH`
|
||||||
|
|
||||||
|
- **MAJOR**: 不兼容的API变更
|
||||||
|
- **MINOR**: 向后兼容的功能新增
|
||||||
|
- **PATCH**: 向后兼容的问题修复
|
||||||
|
|
||||||
|
### 变更类型
|
||||||
|
- **新增功能**: 新的功能特性
|
||||||
|
- **变更**: 现有功能的变更
|
||||||
|
- **弃用**: 即将移除的功能
|
||||||
|
- **移除**: 已移除的功能
|
||||||
|
- **修复**: 问题修复
|
||||||
|
- **安全**: 安全相关的修复
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# SOPA气动式移液器RS485控制指令合集
|
||||||
|
|
||||||
|
## 1. RS485通信基本配置
|
||||||
|
|
||||||
|
### 1.1 支持的设备型号
|
||||||
|
- **仅SC-STxxx-00-13支持RS485通信**
|
||||||
|
- 其他型号主要使用CAN通信
|
||||||
|
|
||||||
|
### 1.2 通信参数
|
||||||
|
- **波特率**: 9600, 115200(默认值)
|
||||||
|
- **地址范围**: 1~254个设备,255为广播地址
|
||||||
|
- **通信接口**: RS485差分信号
|
||||||
|
|
||||||
|
### 1.3 引脚分配(10位LIF连接器)
|
||||||
|
- **引脚7**: RS485+ (RS485通信正极)
|
||||||
|
- **引脚8**: RS485- (RS485通信负极)
|
||||||
|
|
||||||
|
## 2. RS485通信协议格式
|
||||||
|
|
||||||
|
### 2.1 发送数据格式
|
||||||
|
```
|
||||||
|
头码 | 地址 | 命令/数据 | 尾码 | 校验和
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 从机回应格式
|
||||||
|
```
|
||||||
|
头码 | 地址 | 数据(固定9字节) | 尾码 | 校验和
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 格式详细说明
|
||||||
|
- **头码**:
|
||||||
|
- 终端调试: '/' (0x2F)
|
||||||
|
- OEM通信: '[' (0x5B)
|
||||||
|
- **地址**: 设备节点地址,1~254,多字节ASCII(注意:地址不可为47,69,91)
|
||||||
|
- **命令/数据**: ASCII格式的命令字符串
|
||||||
|
- **尾码**: 'E' (0x45)
|
||||||
|
- **校验和**: 以上数据的累加值,1字节
|
||||||
|
|
||||||
|
## 3. 初始化和基本控制指令
|
||||||
|
|
||||||
|
### 3.1 初始化指令
|
||||||
|
```bash
|
||||||
|
# 初始化活塞驱动机构
|
||||||
|
HE
|
||||||
|
|
||||||
|
# 示例(OEM通信):
|
||||||
|
# 主机发送: 5B 32 48 45 1A
|
||||||
|
# 从机回应开始: 2F 02 06 0A 30 00 00 00 00 00 00 45 B6
|
||||||
|
# 从机回应完成: 2F 02 06 00 30 00 00 00 00 00 00 45 AC
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 枪头操作指令
|
||||||
|
```bash
|
||||||
|
# 顶出枪头
|
||||||
|
RE
|
||||||
|
|
||||||
|
# 枪头检测状态报告
|
||||||
|
Q28 # 返回枪头存在状态(0=不存在,1=存在)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 移液控制指令
|
||||||
|
|
||||||
|
### 4.1 位置控制指令
|
||||||
|
```bash
|
||||||
|
# 绝对位置移动(微升)
|
||||||
|
A[n]E
|
||||||
|
# 示例:移动到位置0
|
||||||
|
A0E
|
||||||
|
|
||||||
|
# 相对抽吸(向上移动)
|
||||||
|
P[n]E
|
||||||
|
# 示例:抽吸200微升
|
||||||
|
P200E
|
||||||
|
|
||||||
|
# 相对分配(向下移动)
|
||||||
|
D[n]E
|
||||||
|
# 示例:分配200微升
|
||||||
|
D200E
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 速度设置指令
|
||||||
|
```bash
|
||||||
|
# 设置最高速度(0.1ul/秒为单位)
|
||||||
|
s[n]E
|
||||||
|
# 示例:设置最高速度为2000(200ul/秒)
|
||||||
|
s2000E
|
||||||
|
|
||||||
|
# 设置启动速度
|
||||||
|
b[n]E
|
||||||
|
# 示例:设置启动速度为100(10ul/秒)
|
||||||
|
b100E
|
||||||
|
|
||||||
|
# 设置断流速度
|
||||||
|
c[n]E
|
||||||
|
# 示例:设置断流速度为100(10ul/秒)
|
||||||
|
c100E
|
||||||
|
|
||||||
|
# 设置加速度
|
||||||
|
a[n]E
|
||||||
|
# 示例:设置加速度为30000
|
||||||
|
a30000E
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 液体检测和安全控制指令
|
||||||
|
|
||||||
|
### 5.1 吸排液检测控制
|
||||||
|
```bash
|
||||||
|
# 开启吸排液检测
|
||||||
|
f1E # 开启
|
||||||
|
f0E # 关闭
|
||||||
|
|
||||||
|
# 设置空吸门限
|
||||||
|
$[n]E
|
||||||
|
# 示例:设置空吸门限为4
|
||||||
|
$4E
|
||||||
|
|
||||||
|
# 设置泡沫门限
|
||||||
|
![n]E
|
||||||
|
# 示例:设置泡沫门限为20
|
||||||
|
!20E
|
||||||
|
|
||||||
|
# 设置堵塞门限
|
||||||
|
%[n]E
|
||||||
|
# 示例:设置堵塞门限为350
|
||||||
|
%350E
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 液位检测指令
|
||||||
|
```bash
|
||||||
|
# 压力式液位检测
|
||||||
|
m0E # 设置为压力探测模式
|
||||||
|
L[n]E # 执行液位检测,[n]为灵敏度(3~40)
|
||||||
|
k[n]E # 设置检测速度(100~2000)
|
||||||
|
|
||||||
|
# 电容式液位检测
|
||||||
|
m1E # 设置为电容探测模式
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 状态查询和报告指令
|
||||||
|
|
||||||
|
### 6.1 基本状态查询
|
||||||
|
```bash
|
||||||
|
# 查询固件版本
|
||||||
|
V
|
||||||
|
|
||||||
|
# 查询设备状态
|
||||||
|
Q[n]
|
||||||
|
# 常用查询参数:
|
||||||
|
Q01 # 报告加速度
|
||||||
|
Q02 # 报告启动速度
|
||||||
|
Q03 # 报告断流速度
|
||||||
|
Q06 # 报告最大速度
|
||||||
|
Q08 # 报告节点地址
|
||||||
|
Q11 # 报告波特率
|
||||||
|
Q18 # 报告当前位置
|
||||||
|
Q28 # 报告枪头存在状态
|
||||||
|
Q29 # 报告校准系数
|
||||||
|
Q30 # 报告空吸门限
|
||||||
|
Q31 # 报告堵针门限
|
||||||
|
Q32 # 报告泡沫门限
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 配置和校准指令
|
||||||
|
|
||||||
|
### 7.1 校准参数设置
|
||||||
|
```bash
|
||||||
|
# 设置校准系数
|
||||||
|
j[n]E
|
||||||
|
# 示例:设置校准系数为1.04
|
||||||
|
j1.04E
|
||||||
|
|
||||||
|
# 设置补偿偏差
|
||||||
|
e[n]E
|
||||||
|
# 示例:设置补偿偏差为2.03
|
||||||
|
e2.03E
|
||||||
|
|
||||||
|
# 设置吸头容量
|
||||||
|
C[n]E
|
||||||
|
# 示例:设置1000ul吸头
|
||||||
|
C1000E
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 高级控制参数
|
||||||
|
```bash
|
||||||
|
# 设置回吸粘度
|
||||||
|
][n]E
|
||||||
|
# 示例:设置回吸粘度为30
|
||||||
|
]30E
|
||||||
|
|
||||||
|
# 延时控制
|
||||||
|
M[n]E
|
||||||
|
# 示例:延时1000毫秒
|
||||||
|
M1000E
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 复合操作指令示例
|
||||||
|
|
||||||
|
### 8.1 标准移液操作
|
||||||
|
```bash
|
||||||
|
# 完整的200ul移液操作
|
||||||
|
a30000b200c200s2000P200E
|
||||||
|
# 解析:设置加速度30000 + 启动速度200 + 断流速度200 + 最高速度2000 + 抽吸200ul + 执行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 带检测的移液操作
|
||||||
|
```bash
|
||||||
|
# 带空吸检测的200ul抽吸
|
||||||
|
a30000b200c200s2000f1P200f0E
|
||||||
|
# 解析:设置参数 + 开启检测 + 抽吸200ul + 关闭检测 + 执行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 液面检测操作
|
||||||
|
```bash
|
||||||
|
# 压力式液面检测
|
||||||
|
m0k200L5E
|
||||||
|
# 解析:压力模式 + 检测速度200 + 灵敏度5 + 执行检测
|
||||||
|
|
||||||
|
# 电容式液面检测
|
||||||
|
m1L3E
|
||||||
|
# 解析:电容模式 + 灵敏度3 + 执行检测
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 错误处理
|
||||||
|
|
||||||
|
### 9.1 状态字节说明
|
||||||
|
- **00h**: 无错误
|
||||||
|
- **01h**: 上次动作未完成
|
||||||
|
- **02h**: 设备未初始化
|
||||||
|
- **03h**: 设备过载
|
||||||
|
- **04h**: 无效指令
|
||||||
|
- **05h**: 液位探测故障
|
||||||
|
- **0Dh**: 空吸
|
||||||
|
- **0Eh**: 堵针
|
||||||
|
- **10h**: 泡沫
|
||||||
|
- **11h**: 吸液超过吸头容量
|
||||||
|
|
||||||
|
### 9.2 错误查询
|
||||||
|
```bash
|
||||||
|
# 查询当前错误状态
|
||||||
|
Q # 返回状态字节和错误代码
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 通信示例
|
||||||
|
|
||||||
|
### 10.1 基本通信流程
|
||||||
|
1. **执行命令**: 主机发送命令 → 从机确认 → 从机执行 → 从机回应完成
|
||||||
|
2. **读取数据**: 主机发送查询 → 从机确认 → 从机返回数据
|
||||||
|
|
||||||
|
### 10.2 快速指令表
|
||||||
|
| 操作 | 指令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 初始化 | `HE` | 初始化设备 |
|
||||||
|
| 退枪头 | `RE` | 顶出枪头 |
|
||||||
|
| 吸液200ul | `a30000b200c200s2000P200E` | 基本吸液 |
|
||||||
|
| 带检测吸液 | `a30000b200c200s2000f1P200f0E` | 开启空吸检测 |
|
||||||
|
| 吐液200ul | `a300000b500c500s6000D200E` | 基本分配 |
|
||||||
|
| 压力液面检测 | `m0k200L5E` | pLLD检测 |
|
||||||
|
| 电容液面检测 | `m1L3E` | cLLD检测 |
|
||||||
|
|
||||||
|
## 11. 注意事项
|
||||||
|
|
||||||
|
1. **地址限制**: RS485地址不可设为47、69、91
|
||||||
|
2. **校验和**: 终端调试时不关心校验和,OEM通信需要校验
|
||||||
|
3. **ASCII格式**: 所有命令和参数都使用ASCII字符
|
||||||
|
4. **执行指令**: 大部分命令需要以'E'结尾才能执行
|
||||||
|
5. **设备支持**: 只有SC-STxxx-00-13型号支持RS485通信
|
||||||
|
6. **波特率设置**: 默认115200,可设置为9600
|
||||||
162
unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md
Normal file
162
unilabos/devices/laiyu_liquid/docs/hardware/步进电机控制指令.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# 步进电机B系列控制指令详解
|
||||||
|
|
||||||
|
## 基本通信参数
|
||||||
|
- **通信方式**: RS485
|
||||||
|
- **协议**: Modbus
|
||||||
|
- **波特率**: 115200 (默认)
|
||||||
|
- **数据位**: 8位
|
||||||
|
- **停止位**: 1位
|
||||||
|
- **校验位**: 无
|
||||||
|
- **默认站号**: 1 (可设置1-254)
|
||||||
|
|
||||||
|
## 支持的功能码
|
||||||
|
- **03H**: 读取寄存器
|
||||||
|
- **06H**: 写入单个寄存器
|
||||||
|
- **10H**: 写入多个寄存器
|
||||||
|
|
||||||
|
## 寄存器地址表
|
||||||
|
|
||||||
|
### 状态监控寄存器 (只读)
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 00H | 03H | 电机状态 | 0000H-待机/到位, 0001H-运行中, 0002H-碰撞停, 0003H-正光电停, 0004H-反光电停 |
|
||||||
|
| 01H | 03H | 实际步数高位 | 当前电机位置的高16位 |
|
||||||
|
| 02H | 03H | 实际步数低位 | 当前电机位置的低16位 |
|
||||||
|
| 03H | 03H | 实际速度 | 当前转速 (rpm) |
|
||||||
|
| 05H | 03H | 电流 | 当前工作电流 (mA) |
|
||||||
|
|
||||||
|
### 控制寄存器 (读写)
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 04H | 03H/06H/10H | 急停指令 | 紧急停止控制 |
|
||||||
|
| 06H | 03H/06H/10H | 失能控制 | 1-使能, 0-失能 |
|
||||||
|
| 07H | 03H/06H/10H | PWM输出 | 0-1000对应0%-100%占空比 |
|
||||||
|
| 0EH | 03H/06H/10H | 单圈绝对值归零 | 归零指令 |
|
||||||
|
| 0FH | 03H/06H/10H | 归零指令 | 定点模式归零速度设置 |
|
||||||
|
|
||||||
|
### 位置模式寄存器
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 10H | 03H/06H/10H | 目标步数高位 | 目标位置高16位 |
|
||||||
|
| 11H | 03H/06H/10H | 目标步数低位 | 目标位置低16位 |
|
||||||
|
| 12H | 03H/06H/10H | 保留 | - |
|
||||||
|
| 13H | 03H/06H/10H | 速度 | 运行速度 (rpm) |
|
||||||
|
| 14H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||||
|
| 15H | 03H/06H/10H | 精度 | 到位精度设置 |
|
||||||
|
|
||||||
|
### 速度模式寄存器
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 60H | 03H/06H/10H | 保留 | - |
|
||||||
|
| 61H | 03H/06H/10H | 速度 | 正值正转,负值反转 |
|
||||||
|
| 62H | 03H/06H/10H | 加速度 | 0-60000 rpm/s |
|
||||||
|
|
||||||
|
### 设备参数寄存器
|
||||||
|
| 地址 | 功能码 | 内容 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|--------|------|
|
||||||
|
| E0H | 03H/06H/10H | 设备地址 | 0001H | Modbus从站地址 |
|
||||||
|
| E1H | 03H/06H/10H | 堵转电流 | 0BB8H | 堵转检测电流阈值 |
|
||||||
|
| E2H | 03H/06H/10H | 保留 | 0258H | - |
|
||||||
|
| E3H | 03H/06H/10H | 每圈步数 | 0640H | 细分设置 |
|
||||||
|
| E4H | 03H/06H/10H | 限位开关使能 | F000H | 1-使能, 0-禁用 |
|
||||||
|
| E5H | 03H/06H/10H | 堵转逻辑 | 0000H | 00-断电, 01-对抗 |
|
||||||
|
| E6H | 03H/06H/10H | 堵转时间 | 0000H | 堵转检测时间(ms) |
|
||||||
|
| E7H | 03H/06H/10H | 默认速度 | 1388H | 上电默认速度 |
|
||||||
|
| E8H | 03H/06H/10H | 默认加速度 | EA60H | 上电默认加速度 |
|
||||||
|
| E9H | 03H/06H/10H | 默认精度 | 0064H | 上电默认精度 |
|
||||||
|
| EAH | 03H/06H/10H | 波特率高位 | 0001H | 通信波特率设置 |
|
||||||
|
| EBH | 03H/06H/10H | 波特率低位 | C200H | 115200对应01C200H |
|
||||||
|
|
||||||
|
### 版本信息寄存器 (只读)
|
||||||
|
| 地址 | 功能码 | 内容 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| F0H | 03H | 版本号 | 固件版本信息 |
|
||||||
|
| F1H-F4H | 03H | 型号 | 产品型号信息 |
|
||||||
|
|
||||||
|
## 常用控制指令示例
|
||||||
|
|
||||||
|
### 读取电机状态
|
||||||
|
```
|
||||||
|
发送: 01 03 00 00 00 01 84 0A
|
||||||
|
接收: 01 03 02 00 01 79 84
|
||||||
|
说明: 电机状态为0001H (正在运行)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 读取当前位置
|
||||||
|
```
|
||||||
|
发送: 01 03 00 01 00 02 95 CB
|
||||||
|
接收: 01 03 04 00 19 00 00 2B F4
|
||||||
|
说明: 当前位置为1638400步 (100圈)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 停止电机
|
||||||
|
```
|
||||||
|
发送: 01 10 00 04 00 01 02 00 00 A7 D4
|
||||||
|
接收: 01 10 00 04 00 01 40 08
|
||||||
|
说明: 急停指令
|
||||||
|
```
|
||||||
|
|
||||||
|
### 位置模式运动
|
||||||
|
```
|
||||||
|
发送: 01 10 00 10 00 06 0C 00 19 00 00 00 00 13 88 00 00 00 00 9F FB
|
||||||
|
接收: 01 10 00 10 00 06 41 CE
|
||||||
|
说明: 以5000rpm速度运动到1638400步位置
|
||||||
|
```
|
||||||
|
|
||||||
|
### 速度模式 - 正转
|
||||||
|
```
|
||||||
|
发送: 01 10 00 60 00 04 08 00 00 13 88 00 FA 00 00 F4 77
|
||||||
|
接收: 01 10 00 60 00 04 C1 D4
|
||||||
|
说明: 以5000rpm速度正转
|
||||||
|
```
|
||||||
|
|
||||||
|
### 速度模式 - 反转
|
||||||
|
```
|
||||||
|
发送: 01 10 00 60 00 04 08 00 00 EC 78 00 FA 00 00 A0 6D
|
||||||
|
接收: 01 10 00 60 00 04 C1 D4
|
||||||
|
说明: 以5000rpm速度反转 (EC78H = -5000)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置设备地址
|
||||||
|
```
|
||||||
|
发送: 00 06 00 E0 00 02 C9 F1
|
||||||
|
接收: 00 06 00 E0 00 02 C9 F1
|
||||||
|
说明: 将设备地址设置为2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
| 状态码 | 含义 |
|
||||||
|
|--------|------|
|
||||||
|
| 0001H | 功能码错误 |
|
||||||
|
| 0002H | 地址错误 |
|
||||||
|
| 0003H | 长度错误 |
|
||||||
|
|
||||||
|
## CRC校验算法
|
||||||
|
```c
|
||||||
|
public static byte[] ModBusCRC(byte[] data, int offset, int cnt) {
|
||||||
|
int wCrc = 0x0000FFFF;
|
||||||
|
byte[] CRC = new byte[2];
|
||||||
|
for (int i = 0; i < cnt; i++) {
|
||||||
|
wCrc ^= ((data[i + offset]) & 0xFF);
|
||||||
|
for (int j = 0; j < 8; j++) {
|
||||||
|
if ((wCrc & 0x00000001) == 1) {
|
||||||
|
wCrc >>= 1;
|
||||||
|
wCrc ^= 0x0000A001;
|
||||||
|
} else {
|
||||||
|
wCrc >>= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CRC[1] = (byte) ((wCrc & 0x0000FF00) >> 8);
|
||||||
|
CRC[0] = (byte) (wCrc & 0x000000FF);
|
||||||
|
return CRC;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
1. 所有16位数据采用大端序传输
|
||||||
|
2. 步数计算: 实际步数 = 高位<<16 | 低位
|
||||||
|
3. 负数使用补码表示
|
||||||
|
4. PWM输出K脚: 0%开漏, 100%接地, 其他输出1KHz PWM
|
||||||
|
5. 光电开关需使用NPN开漏型
|
||||||
|
6. 限位开关: LF正向, LB反向
|
||||||
1281
unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md
Normal file
1281
unilabos/devices/laiyu_liquid/docs/hardware/硬件连接配置指南.md
Normal file
File diff suppressed because it is too large
Load Diff
269
unilabos/devices/laiyu_liquid/docs/readme.md
Normal file
269
unilabos/devices/laiyu_liquid/docs/readme.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# LaiYu_Liquid 液体处理工作站
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
LaiYu_Liquid 是一个完全集成到 UniLabOS 的自动化液体处理工作站,基于 RS485 通信协议,专为精确的液体分配和转移操作而设计。本模块已完成生产环境部署准备,提供完整的硬件控制、资源管理和标准化接口。
|
||||||
|
|
||||||
|
## 系统组成
|
||||||
|
|
||||||
|
### 硬件组件
|
||||||
|
- **XYZ三轴运动平台**: 3个RS485步进电机驱动(地址:X轴=0x01, Y轴=0x02, Z轴=0x03)
|
||||||
|
- **SOPA气动式移液器**: RS485总线控制,支持精密液体处理操作
|
||||||
|
- **通信接口**: RS485转USB模块,默认波特率115200
|
||||||
|
- **机械结构**: 稳固工作台面,支持离心管架、96孔板等标准实验耗材
|
||||||
|
|
||||||
|
### 软件架构
|
||||||
|
- **驱动层**: 底层硬件通信驱动,支持RS485协议
|
||||||
|
- **控制层**: 高级控制逻辑和坐标系管理
|
||||||
|
- **抽象层**: 完全符合UniLabOS标准的液体处理接口
|
||||||
|
- **资源层**: 标准化的实验器具和耗材管理
|
||||||
|
|
||||||
|
## 🎯 生产就绪组件
|
||||||
|
|
||||||
|
### ✅ 核心驱动程序 (`drivers/`)
|
||||||
|
- **`sopa_pipette_driver.py`** - SOPA移液器完整驱动
|
||||||
|
- 支持液体吸取、分配、检测
|
||||||
|
- 完整的错误处理和状态管理
|
||||||
|
- 生产级别的通信协议实现
|
||||||
|
|
||||||
|
- **`xyz_stepper_driver.py`** - XYZ三轴步进电机驱动
|
||||||
|
- 精确的位置控制和运动规划
|
||||||
|
- 安全限位和错误检测
|
||||||
|
- 高性能运动控制算法
|
||||||
|
|
||||||
|
### ✅ 高级控制器 (`controllers/`)
|
||||||
|
- **`pipette_controller.py`** - 移液控制器
|
||||||
|
- 封装高级液体处理功能
|
||||||
|
- 支持多种液体类型和处理参数
|
||||||
|
- 智能错误恢复机制
|
||||||
|
|
||||||
|
- **`xyz_controller.py`** - XYZ运动控制器
|
||||||
|
- 坐标系管理和转换
|
||||||
|
- 运动路径优化
|
||||||
|
- 安全运动控制
|
||||||
|
|
||||||
|
### ✅ UniLabOS集成 (`core/LaiYu_Liquid.py`)
|
||||||
|
- **完整的液体处理抽象接口**
|
||||||
|
- **标准化的资源管理系统**
|
||||||
|
- **与PyLabRobot兼容的后端实现**
|
||||||
|
- **生产级别的错误处理和日志记录**
|
||||||
|
|
||||||
|
### ✅ 资源管理系统
|
||||||
|
- **`laiyu_liquid_res.py`** - 标准化资源定义
|
||||||
|
- 96孔板、离心管架、枪头架等标准器具
|
||||||
|
- 自动化的资源创建和配置函数
|
||||||
|
- 与工作台布局的完美集成
|
||||||
|
|
||||||
|
### ✅ 配置管理 (`config/`)
|
||||||
|
- **`config/deck.json`** - 工作台布局配置
|
||||||
|
- 精确的空间定义和槽位管理
|
||||||
|
- 支持多种实验器具的标准化放置
|
||||||
|
- 可扩展的配置架构
|
||||||
|
|
||||||
|
- **`__init__.py`** - 模块集成和导出
|
||||||
|
- 完整的API导出和版本管理
|
||||||
|
- 依赖检查和安装验证
|
||||||
|
- 专业的模块信息展示
|
||||||
|
|
||||||
|
<!-- ### ✅ 可视化支持
|
||||||
|
- **`rviz_backend.py`** - RViz可视化后端
|
||||||
|
- 实时运动状态可视化
|
||||||
|
- 液体处理过程监控
|
||||||
|
- 与ROS系统的无缝集成 -->
|
||||||
|
|
||||||
|
## 🚀 核心功能特性
|
||||||
|
|
||||||
|
### 液体处理能力
|
||||||
|
- **精密体积控制**: 支持1-1000μL精确分配
|
||||||
|
- **多种液体类型**: 水性、有机溶剂、粘稠液体等
|
||||||
|
- **智能检测**: 液位检测、气泡检测、堵塞检测
|
||||||
|
- **自动化流程**: 完整的吸取-转移-分配工作流
|
||||||
|
|
||||||
|
### 运动控制系统
|
||||||
|
- **三轴精密定位**: 微米级精度控制
|
||||||
|
- **路径优化**: 智能运动规划和碰撞避免
|
||||||
|
- **安全机制**: 限位保护、紧急停止、错误恢复
|
||||||
|
- **坐标系管理**: 工作坐标与机械坐标的自动转换
|
||||||
|
|
||||||
|
### 资源管理
|
||||||
|
- **标准化器具**: 支持96孔板、离心管架、枪头架等
|
||||||
|
- **状态跟踪**: 实时监控液体体积、枪头状态等
|
||||||
|
- **自动配置**: 基于JSON的灵活配置系统
|
||||||
|
- **扩展性**: 易于添加新的器具类型
|
||||||
|
|
||||||
|
## 📁 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
LaiYu_Liquid/
|
||||||
|
├── __init__.py # 模块初始化和API导出
|
||||||
|
├── readme.md # 本文档
|
||||||
|
├── backend/ # 后端驱动模块
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── laiyu_backend.py # PyLabRobot兼容后端
|
||||||
|
├── core/ # 核心模块
|
||||||
|
│ ├── core/
|
||||||
|
│ │ └── LaiYu_Liquid.py # 主设备类
|
||||||
|
│ ├── abstract_protocol.py # 抽象协议
|
||||||
|
│ └── laiyu_liquid_res.py # 设备资源定义
|
||||||
|
├── config/ # 配置文件目录
|
||||||
|
│ └── deck.json # 工作台布局配置
|
||||||
|
├── controllers/ # 高级控制器
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── pipette_controller.py # 移液控制器
|
||||||
|
│ └── xyz_controller.py # XYZ运动控制器
|
||||||
|
├── docs/ # 技术文档
|
||||||
|
│ ├── SOPA气动式移液器RS485控制指令.md
|
||||||
|
│ ├── 步进电机控制指令.md
|
||||||
|
│ └── hardware/ # 硬件相关文档
|
||||||
|
├── drivers/ # 底层驱动程序
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── sopa_pipette_driver.py # SOPA移液器驱动
|
||||||
|
│ └── xyz_stepper_driver.py # XYZ步进电机驱动
|
||||||
|
└── tests/ # 测试文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 快速开始
|
||||||
|
|
||||||
|
### 1. 安装和验证
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 验证模块安装
|
||||||
|
from unilabos.devices.laiyu_liquid import (
|
||||||
|
LaiYuLiquid,
|
||||||
|
LaiYuLiquidConfig,
|
||||||
|
create_quick_setup,
|
||||||
|
print_module_info
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查看模块信息
|
||||||
|
print_module_info()
|
||||||
|
|
||||||
|
# 快速创建默认资源
|
||||||
|
resources = create_quick_setup()
|
||||||
|
print(f"已创建 {len(resources)} 个资源")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 基本使用示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.LaiYu_Liquid import (
|
||||||
|
create_quick_setup,
|
||||||
|
create_96_well_plate,
|
||||||
|
create_laiyu_backend
|
||||||
|
)
|
||||||
|
|
||||||
|
# 快速创建默认资源
|
||||||
|
resources = create_quick_setup()
|
||||||
|
print(f"创建了以下资源: {list(resources.keys())}")
|
||||||
|
|
||||||
|
# 创建96孔板
|
||||||
|
plate_96 = create_96_well_plate("test_plate")
|
||||||
|
print(f"96孔板包含 {len(plate_96.children)} 个孔位")
|
||||||
|
|
||||||
|
# 创建后端实例(用于PyLabRobot集成)
|
||||||
|
backend = create_laiyu_backend("LaiYu_Device")
|
||||||
|
print(f"后端设备: {backend.name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 后端驱动使用
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.laiyu_liquid.backend import create_laiyu_backend
|
||||||
|
|
||||||
|
# 创建后端实例
|
||||||
|
backend = create_laiyu_backend("LaiYu_Liquid_Station")
|
||||||
|
|
||||||
|
# 连接设备
|
||||||
|
await backend.connect()
|
||||||
|
|
||||||
|
# 设备归位
|
||||||
|
await backend.home_device()
|
||||||
|
|
||||||
|
# 获取设备状态
|
||||||
|
status = await backend.get_status()
|
||||||
|
print(f"设备状态: {status}")
|
||||||
|
|
||||||
|
# 断开连接
|
||||||
|
await backend.disconnect()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 资源管理示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unilabos.devices.LaiYu_Liquid import (
|
||||||
|
create_centrifuge_tube_rack,
|
||||||
|
create_tip_rack,
|
||||||
|
load_deck_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# 加载工作台配置
|
||||||
|
deck_config = load_deck_config()
|
||||||
|
print(f"工作台尺寸: {deck_config['size_x']}x{deck_config['size_y']}mm")
|
||||||
|
|
||||||
|
# 创建不同类型的资源
|
||||||
|
tube_rack = create_centrifuge_tube_rack("sample_rack")
|
||||||
|
tip_rack = create_tip_rack("tip_rack_200ul")
|
||||||
|
|
||||||
|
print(f"离心管架: {tube_rack.name}, 容量: {len(tube_rack.children)} 个位置")
|
||||||
|
print(f"枪头架: {tip_rack.name}, 容量: {len(tip_rack.children)} 个枪头")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 技术架构
|
||||||
|
|
||||||
|
### 坐标系统
|
||||||
|
- **机械坐标**: 基于步进电机的原始坐标系统
|
||||||
|
- **工作坐标**: 用户友好的实验室坐标系统
|
||||||
|
- **自动转换**: 透明的坐标系转换和校准
|
||||||
|
|
||||||
|
### 通信协议
|
||||||
|
- **RS485总线**: 高可靠性工业通信标准
|
||||||
|
- **Modbus协议**: 标准化的设备通信协议
|
||||||
|
- **错误检测**: 完整的通信错误检测和恢复
|
||||||
|
|
||||||
|
### 安全机制
|
||||||
|
- **限位保护**: 硬件和软件双重限位保护
|
||||||
|
- **紧急停止**: 即时停止所有运动和操作
|
||||||
|
- **状态监控**: 实时设备状态监控和报警
|
||||||
|
|
||||||
|
## 🧪 验证和测试
|
||||||
|
|
||||||
|
### 功能验证
|
||||||
|
```python
|
||||||
|
# 验证模块安装
|
||||||
|
from unilabos.devices.laiyu_liquid import validate_installation
|
||||||
|
validate_installation()
|
||||||
|
|
||||||
|
# 查看模块信息
|
||||||
|
from unilabos.devices.laiyu_liquid import print_module_info
|
||||||
|
print_module_info()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 硬件连接测试
|
||||||
|
```python
|
||||||
|
# 测试SOPA移液器连接
|
||||||
|
from unilabos.devices.laiyu_liquid.drivers import SOPAPipette, SOPAConfig
|
||||||
|
|
||||||
|
config = SOPAConfig(port="/dev/cu.usbserial-3130", address=4)
|
||||||
|
pipette = SOPAPipette(config)
|
||||||
|
success = pipette.connect()
|
||||||
|
print(f"SOPA连接状态: {'成功' if success else '失败'}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 维护和支持
|
||||||
|
|
||||||
|
### 日志记录
|
||||||
|
- **结构化日志**: 使用Python logging模块的专业日志记录
|
||||||
|
- **错误追踪**: 详细的错误信息和堆栈跟踪
|
||||||
|
- **性能监控**: 操作时间和性能指标记录
|
||||||
|
|
||||||
|
### 配置管理
|
||||||
|
- **JSON配置**: 灵活的JSON格式配置文件
|
||||||
|
- **参数验证**: 自动配置参数验证和错误提示
|
||||||
|
- **热重载**: 支持配置文件的动态重载
|
||||||
|
|
||||||
|
### 扩展性
|
||||||
|
- **模块化设计**: 易于扩展和定制的模块化架构
|
||||||
|
- **插件接口**: 支持第三方插件和扩展
|
||||||
|
- **API兼容**: 向后兼容的API设计
|
||||||
|
|
||||||
|
|
||||||
30
unilabos/devices/laiyu_liquid/drivers/__init__.py
Normal file
30
unilabos/devices/laiyu_liquid/drivers/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
LaiYu_Liquid 驱动程序模块
|
||||||
|
|
||||||
|
该模块包含了LaiYu_Liquid液体处理工作站的硬件驱动程序:
|
||||||
|
- SOPA移液器驱动程序
|
||||||
|
- XYZ步进电机驱动程序
|
||||||
|
"""
|
||||||
|
|
||||||
|
# SOPA移液器驱动程序导入
|
||||||
|
from .sopa_pipette_driver import SOPAPipette, SOPAConfig, SOPAStatusCode
|
||||||
|
|
||||||
|
# XYZ步进电机驱动程序导入
|
||||||
|
from .xyz_stepper_driver import StepperMotorDriver, XYZStepperController, MotorAxis, MotorStatus
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# SOPA移液器
|
||||||
|
"SOPAPipette",
|
||||||
|
"SOPAConfig",
|
||||||
|
"SOPAStatusCode",
|
||||||
|
|
||||||
|
# XYZ步进电机
|
||||||
|
"StepperMotorDriver",
|
||||||
|
"XYZStepperController",
|
||||||
|
"MotorAxis",
|
||||||
|
"MotorStatus",
|
||||||
|
]
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
__author__ = "LaiYu_Liquid Driver Team"
|
||||||
|
__description__ = "LaiYu_Liquid 硬件驱动程序集合"
|
||||||
1079
unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
Normal file
1079
unilabos/devices/laiyu_liquid/drivers/sopa_pipette_driver.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user