Compare commits

..

56 Commits

Author SHA1 Message Date
ZiWei
827169827a Remove outdated templates and validation guides for device and experiment graph skills.
- Deleted `templates.md` for workstation code templates.
- Removed `SKILL.md` and `reference.md` for editing experiment graphs.
- Deleted `SKILL.md` for validating device implementations.
2026-04-27 11:20:17 +08:00
ZiWei
c4a2f68649 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-04-27 11:13:13 +08:00
Xuwznln
195fad9398 upgrade to 0.11.1 2026-04-22 19:54:16 +08:00
Xuwznln
898ed5d34b use gitee to install pylabrobot
fix virtual import
2026-04-22 19:51:10 +08:00
Xuwznln
60cbedc4b2 v0.11.0
(cherry picked from commit 67a74172dc)
2026-04-22 19:50:42 +08:00
Xuwznln
2d6a9f7db9 fix possible conversion error 2026-04-22 00:09:06 +08:00
Xuwznln
5dca3d8c3d update workbench example 2026-04-15 16:33:43 +08:00
Xuwznln
37cbed722a update aksk desc 2026-04-13 23:17:43 +08:00
Xuwznln
132cffbe7c print res query logs 2026-04-13 20:24:48 +08:00
Xuwznln
36e5ff804c Fix skills exec error with action type 2026-04-13 20:16:00 +08:00
Xuwznln
eaf8ad5609 Fix skills exec error with action type 2026-04-13 17:02:38 +08:00
Xuwznln
16122ad2fa Update Skills 2026-04-13 15:57:50 +08:00
Xuwznln
d3fef85dd8 Update Skills addr 2026-04-13 11:15:35 +08:00
Xuwznln
f77ac2a5e8 Change uni-lab. to leap-lab.
Support unit in pylabrobot
2026-04-12 15:32:27 +08:00
Xuwznln
93ac55a65b Support async func. 2026-04-11 18:13:08 +08:00
Xuwznln
af35debe38 change to leap-lab backend. Support feedback interval. Reduce cocurrent lags. 2026-04-11 06:22:53 +08:00
Xuwznln
58997f0654 fix create_resource_with_slot 2026-04-09 17:34:25 +08:00
Xuwznln
fbfc3e30fb update unilabos_formulation & batch-submit-exp 2026-04-09 16:40:31 +08:00
Xuwznln
1d1c1367df scale multi exec thread up to 48 2026-04-09 14:15:38 +08:00
Xuwznln
c91b600e90 update handle creation api 2026-04-02 22:53:31 +08:00
Xuwznln
49b3c850f9 fit cocurrent gap 2026-04-02 16:01:23 +08:00
Xuwznln
25c94af755 add running status debounce 2026-04-01 16:01:22 +08:00
Xuwznln
861a012747 allow non @topic_config support 2026-03-31 13:15:06 +08:00
ZiWei
d68fc5e380 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction
# Conflicts:
#	.cursor/skills/add-workstation/SKILL.md
#	.cursor/skills/add-workstation/reference.md
2026-03-27 11:49:30 +08:00
Xuwznln
ee63e95f50 update skill 2026-03-25 23:20:13 +08:00
Xuwznln
dbf5df6e4d add placeholder keys 2026-03-25 12:11:10 +08:00
Xuwznln
f10c0343ce add placeholder keys 2026-03-25 11:56:40 +08:00
Xuwznln
8b6553bdd9 always free 2026-03-25 11:24:19 +08:00
Xuwznln
e7a4afd6b5 提交实验技能 2026-03-25 00:42:28 +08:00
Xuwznln
f18f6d82fc disable samples 2026-03-24 23:45:50 +08:00
Xuwznln
b7c726635c correct sample demo ret value 2026-03-24 23:24:12 +08:00
Xuwznln
c809912fd3 新增试剂reagent 2026-03-24 23:22:45 +08:00
Xuwznln
d956b27e9f update registry 2026-03-24 23:10:57 +08:00
Xuwznln
ff1e21fcd8 新增manual_confirm 2026-03-24 23:04:00 +08:00
Xuwznln
b9d9666003 add workstation creation skill 2026-03-24 23:03:49 +08:00
ZiWei
f0ea32f163 Merge remote-tracking branch 'origin/dev' into feature/organic-extraction
# Conflicts:
#	.cursor/skills/add-device/SKILL.md
#	.cursor/skills/add-resource/SKILL.md
#	AGENTS.md
#	CLAUDE.md
2026-03-24 17:06:54 +08:00
Junhan Chang
d776550a4b add virtual_sample_demo 样品追踪测试设备 2026-03-23 16:43:20 +08:00
Xuwznln
3d8123849a add external devices param
fix registry upload missing type
2026-03-23 15:01:16 +08:00
ZiWei
3c8020813b feat: 添加设备验证指南,确保设备实现符合接口契约和编码标准 2026-03-17 09:54:12 +08:00
ZiWei
97996d316f Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-03-13 15:07:30 +08:00
ZiWei
9815961a1f feat: Add new developer guides for old devices and PLC framework integration 2026-03-11 14:10:11 +08:00
ZiWei
fe501c965f feat: Update workstation reference and templates with new PLC integration details and enhanced workflow mappings 2026-03-11 14:09:46 +08:00
ZiWei
92bfb069d5 feat: Implement Laiyu liquid handling station with enhanced device control, testing, and documentation. 2026-03-09 18:44:20 +08:00
ZiWei
b61c818f7f Merge remote-tracking branch 'origin/dev' into feature/organic-extraction 2026-03-09 09:39:17 +08:00
ZiWei
47a29a0c2f add:skill&agent 2026-03-06 16:54:31 +08:00
ZiWei
9c6f7c7505 Merge branch 'dev' into feature/organic-extraction 2026-03-02 15:32:36 +08:00
ZiWei
e4e4bfbe20 Merge branch 'dev' into feature/organic-extraction 2026-02-04 15:47:47 +08:00
ZiWei
64c748d921 Merge branch 'vibe/dev' into feature/organic-extraction 2026-02-03 10:39:44 +08:00
ZiWei
15ff0e9d30 feat: add Bioyond deck imports to resource registration 2026-02-03 10:28:51 +08:00
ZiWei
f8a52860ad Add BIOYOND deck imports and update JSON configurations with new UUIDs for various components 2026-02-03 10:25:47 +08:00
Xuwznln
e30c01d54e Dev backward (#228)
* Workbench example, adjust log level, and ci check (#220)

* TestLatency Return Value Example & gitignore update

* Adjust log level & Add workbench virtual example & Add not action decorator & Add check_mode &

* Add CI Check

* CI Check Fix 1

* CI Check Fix 2

* CI Check Fix 3

* CI Check Fix 4

* CI Check Fix 5

* Upgrade to py 3.11.14; ros 0.7; unilabos 0.10.16

* Update to ROS2 Humble 0.7

* Fix Build 1

* Fix Build 2

* Fix Build 3

* Fix Build 4

* Fix Build 5

* Fix Build 6

* Fix Build 7

* ci(deps): bump actions/configure-pages from 4 to 5 (#222)

Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/upload-artifact from 4 to 6 (#224)

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/upload-pages-artifact from 3 to 4 (#225)

Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump actions/checkout from 4 to 6 (#223)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Fix Build 8

* Fix Build 9

* Fix Build 10

* Fix Build 11

* Fix Build 12

* Fix Build 13

* v0.10.17

(cherry picked from commit 176de521b4)

* CI Check use production mode

* Fix OT2 & ReAdd Virtual Devices

* add msg goal

* transfer liquid handles

* gather query

* add unilabos_class

* Support root node change pos

* save class name when deserialize & protocol execute test

* fix upload workflow json

* workflow upload & set liquid fix & add set liquid with plate

* speed up registry load

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hanhua@dp.tech <2509856570@qq.com>
2026-02-02 23:57:13 +08:00
ZiWei
37ec49f318 Refactor Bioyond resource handling: update warehouse mapping retrieval, add TipBox support, and improve liquid tracking logic. Migrate TipBox creation to bottle_carriers.py for better structure. 2026-01-29 16:31:14 +08:00
ZiWei
6bf57f18c1 Collaboration With Cursor 2026-01-29 11:29:38 +08:00
ZiWei
c4a3be1498 feat: enhance separation_step logic with polling thread management and error handling 2026-01-27 12:37:09 +08:00
ZiWei
e11070315d feat: add separation_step with sensor-motor linkage 2026-01-26 23:34:47 +08:00
ZiWei
50ebcad9d7 feat: add ZDT_X42 motor and XKC sensor drivers 2026-01-22 15:07:32 +08:00
104 changed files with 12746 additions and 1421 deletions

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos name: unilabos
version: 0.10.19 version: 0.11.1
source: source:
path: ../../unilabos path: ../../unilabos
@@ -54,7 +54,7 @@ requirements:
- pymodbus - pymodbus
- matplotlib - matplotlib
- pylibftdi - pylibftdi
- uni-lab::unilabos-env ==0.10.19 - uni-lab::unilabos-env ==0.11.1
about: about:
repository: https://github.com/deepmodeling/Uni-Lab-OS repository: https://github.com/deepmodeling/Uni-Lab-OS

View File

@@ -2,7 +2,7 @@
package: package:
name: unilabos-env name: unilabos-env
version: 0.10.19 version: 0.11.1
build: build:
noarch: generic noarch: generic

View File

@@ -3,7 +3,7 @@
package: package:
name: unilabos-full name: unilabos-full
version: 0.10.19 version: 0.11.1
build: build:
noarch: generic noarch: generic
@@ -11,7 +11,7 @@ build:
requirements: requirements:
run: run:
# Base unilabos package (includes unilabos-env) # Base unilabos package (includes unilabos-env)
- uni-lab::unilabos ==0.10.19 - uni-lab::unilabos ==0.11.1
# Documentation tools # Documentation tools
- sphinx - sphinx
- sphinx_rtd_theme - sphinx_rtd_theme

View File

@@ -0,0 +1,328 @@
---
description: 设备驱动开发规范
globs: ["unilabos/devices/**/*.py"]
---
# 设备驱动开发规范
## 目录结构
```
unilabos/devices/
├── virtual/ # 虚拟设备(用于测试)
│ ├── virtual_stirrer.py
│ └── virtual_centrifuge.py
├── liquid_handling/ # 液体处理设备
├── balance/ # 天平设备
├── hplc/ # HPLC设备
├── pump_and_valve/ # 泵和阀门
├── temperature/ # 温度控制设备
├── workstation/ # 工作站(组合设备)
└── ...
```
## 设备类完整模板
```python
import asyncio
import logging
import time as time_module
from typing import Dict, Any, Optional
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class MyDevice:
"""
设备类描述
Attributes:
device_id: 设备唯一标识
config: 设备配置字典
data: 设备状态数据
"""
_ros_node: BaseROS2DeviceNode
def __init__(
self,
device_id: str = None,
config: Dict[str, Any] = None,
**kwargs
):
"""
初始化设备
Args:
device_id: 设备ID
config: 配置字典
**kwargs: 其他参数
"""
# 兼容不同调用方式
if device_id is None and 'id' in kwargs:
device_id = kwargs.pop('id')
if config is None and 'config' in kwargs:
config = kwargs.pop('config')
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.data = {}
# 从config读取参数
self.port = self.config.get('port') or kwargs.get('port', 'COM1')
self._max_value = self.config.get('max_value', 1000.0)
# 初始化日志
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.logger.info(f"设备 {self.device_id} 已创建")
def post_init(self, ros_node: BaseROS2DeviceNode):
"""
ROS节点注入 - 在ROS节点创建后调用
Args:
ros_node: ROS2设备节点实例
"""
self._ros_node = ros_node
async def initialize(self) -> bool:
"""
初始化设备 - 连接硬件、设置初始状态
Returns:
bool: 初始化是否成功
"""
self.logger.info(f"初始化设备 {self.device_id}")
try:
# 执行硬件初始化
# await self._connect_hardware()
# 设置初始状态
self.data.update({
"status": "待机",
"is_running": False,
"current_value": 0.0,
})
self.logger.info(f"设备 {self.device_id} 初始化完成")
return True
except Exception as e:
self.logger.error(f"初始化失败: {e}")
self.data["status"] = f"错误: {e}"
return False
async def cleanup(self) -> bool:
"""
清理设备 - 断开连接、释放资源
Returns:
bool: 清理是否成功
"""
self.logger.info(f"清理设备 {self.device_id}")
self.data.update({
"status": "离线",
"is_running": False,
})
return True
# ==================== 设备动作 ====================
async def execute_action(
self,
param1: float,
param2: str = "",
**kwargs
) -> bool:
"""
执行设备动作
Args:
param1: 参数1
param2: 参数2可选
Returns:
bool: 动作是否成功
"""
# 类型转换和验证
try:
param1 = float(param1)
except (ValueError, TypeError) as e:
self.logger.error(f"参数类型错误: {e}")
return False
# 参数验证
if param1 > self._max_value:
self.logger.error(f"参数超出范围: {param1} > {self._max_value}")
return False
self.logger.info(f"执行动作: param1={param1}, param2={param2}")
# 更新状态
self.data.update({
"status": "运行中",
"is_running": True,
})
# 执行动作(带进度反馈)
duration = 10.0 # 秒
start_time = time_module.time()
while True:
elapsed = time_module.time() - start_time
remaining = max(0, duration - elapsed)
progress = min(100, (elapsed / duration) * 100)
self.data.update({
"status": f"运行中: {progress:.0f}%",
"remaining_time": remaining,
})
if remaining <= 0:
break
await self._ros_node.sleep(1.0)
# 完成
self.data.update({
"status": "完成",
"is_running": False,
})
self.logger.info("动作执行完成")
return True
# ==================== 状态属性 ====================
@property
def status(self) -> str:
"""设备状态 - 自动发布为ROS Topic"""
return self.data.get("status", "未知")
@property
def is_running(self) -> bool:
"""是否正在运行"""
return self.data.get("is_running", False)
@property
def current_value(self) -> float:
"""当前值"""
return self.data.get("current_value", 0.0)
# ==================== 辅助方法 ====================
def get_device_info(self) -> Dict[str, Any]:
"""获取设备信息"""
return {
"device_id": self.device_id,
"status": self.status,
"is_running": self.is_running,
"current_value": self.current_value,
}
def __str__(self) -> str:
return f"MyDevice({self.device_id}: {self.status})"
```
## 关键规则
### 1. 参数处理
所有动作方法的参数都可能以字符串形式传入,必须进行类型转换:
```python
async def my_action(self, value: float, **kwargs) -> bool:
# 始终进行类型转换
try:
value = float(value)
except (ValueError, TypeError) as e:
self.logger.error(f"参数类型错误: {e}")
return False
```
### 2. vessel 参数处理
vessel 参数可能是字符串ID或字典
```python
def extract_vessel_id(vessel: Union[str, dict]) -> str:
if isinstance(vessel, dict):
return vessel.get("id", "")
return str(vessel) if vessel else ""
```
### 3. 状态更新
使用 `self.data` 字典存储状态,属性读取状态:
```python
# 更新状态
self.data["status"] = "运行中"
self.data["current_speed"] = 300.0
# 读取状态(通过属性)
@property
def status(self) -> str:
return self.data.get("status", "待机")
```
### 4. 异步等待
使用 ROS 节点的 sleep 方法:
```python
# 正确
await self._ros_node.sleep(1.0)
# 避免(除非在纯 Python 测试环境)
await asyncio.sleep(1.0)
```
### 5. 进度反馈
长时间运行的操作需要提供进度反馈:
```python
while remaining > 0:
progress = (elapsed / total_time) * 100
self.data["status"] = f"运行中: {progress:.0f}%"
self.data["remaining_time"] = remaining
await self._ros_node.sleep(1.0)
```
## 虚拟设备
虚拟设备用于测试和演示,放在 `unilabos/devices/virtual/` 目录:
- 类名以 `Virtual` 开头
- 文件名以 `virtual_` 开头
- 模拟真实设备的行为和时序
- 使用表情符号增强日志可读性(可选)
## 工作站设备
工作站是组合多个设备的复杂设备:
```python
from unilabos.devices.workstation.workstation_base import WorkstationBase
class MyWorkstation(WorkstationBase):
"""组合工作站"""
async def execute_workflow(self, workflow: Dict[str, Any]) -> bool:
"""执行工作流"""
pass
```
## 设备注册
设备类开发完成后,需要在注册表中注册:
1. 创建/编辑 `unilabos/registry/devices/my_category.yaml`
2. 添加设备配置(参考 `virtual_device.yaml`
3. 运行 `--complete_registry` 自动生成 schema

View File

@@ -0,0 +1,240 @@
---
description: 协议编译器开发规范
globs: ["unilabos/compile/**/*.py"]
---
# 协议编译器开发规范
## 概述
协议编译器负责将高级实验操作(如 Stir、Add、Filter编译为设备可执行的动作序列。
## 文件命名
- 位置: `unilabos/compile/`
- 命名: `{operation}_protocol.py`
- 示例: `stir_protocol.py`, `add_protocol.py`, `filter_protocol.py`
## 协议函数模板
```python
from typing import List, Dict, Any, Union
import networkx as nx
import logging
from .utils.unit_parser import parse_time_input
from .utils.vessel_parser import extract_vessel_id
logger = logging.getLogger(__name__)
def generate_{operation}_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
param1: Union[str, float] = "0",
param2: float = 0.0,
**kwargs
) -> List[Dict[str, Any]]:
"""
生成{操作}协议序列
Args:
G: 物理拓扑图 (NetworkX DiGraph)
vessel: 容器ID或Resource字典
param1: 参数1支持字符串单位如 "5 min"
param2: 参数2
**kwargs: 其他参数
Returns:
List[Dict]: 动作序列
Raises:
ValueError: 参数无效时
"""
# 1. 提取 vessel_id
vessel_id = extract_vessel_id(vessel)
# 2. 验证参数
if not vessel_id:
raise ValueError("vessel 参数不能为空")
if vessel_id not in G.nodes():
raise ValueError(f"容器 '{vessel_id}' 不存在于系统中")
# 3. 解析参数(支持单位)
parsed_param1 = parse_time_input(param1) # "5 min" -> 300.0
# 4. 查找设备
device_id = find_connected_device(G, vessel_id, device_type="my_device")
# 5. 生成动作序列
action_sequence = []
action = {
"device_id": device_id,
"action_name": "my_action",
"action_kwargs": {
"vessel": {"id": vessel_id}, # 始终使用字典格式
"param1": float(parsed_param1),
"param2": float(param2),
}
}
action_sequence.append(action)
logger.info(f"生成协议: {len(action_sequence)} 个动作")
return action_sequence
def find_connected_device(
G: nx.DiGraph,
vessel_id: str,
device_type: str = ""
) -> str:
"""
查找与容器相连的设备
Args:
G: 拓扑图
vessel_id: 容器ID
device_type: 设备类型关键字
Returns:
str: 设备ID
"""
# 查找所有匹配类型的设备
device_nodes = []
for node in G.nodes():
node_class = G.nodes[node].get('class', '') or ''
if device_type.lower() in node_class.lower():
device_nodes.append(node)
# 检查连接
if vessel_id and device_nodes:
for device in device_nodes:
if G.has_edge(device, vessel_id) or G.has_edge(vessel_id, device):
return device
# 返回第一个可用设备
if device_nodes:
return device_nodes[0]
# 默认设备
return f"{device_type}_1"
```
## 关键规则
### 1. vessel 参数处理
vessel 参数可能是字符串或字典,需要统一处理:
```python
def extract_vessel_id(vessel: Union[str, dict]) -> str:
"""提取vessel_id"""
if isinstance(vessel, dict):
# 可能是 {"id": "xxx"} 或完整 Resource 对象
return vessel.get("id", list(vessel.values())[0].get("id", ""))
return str(vessel) if vessel else ""
```
### 2. action_kwargs 中的 vessel
始终使用 `{"id": vessel_id}` 格式传递 vessel
```python
# 正确
"action_kwargs": {
"vessel": {"id": vessel_id}, # 字符串ID包装为字典
}
# 避免
"action_kwargs": {
"vessel": vessel_resource, # 不要传递完整 Resource 对象
}
```
### 3. 单位解析
使用 `parse_time_input` 解析时间参数:
```python
from .utils.unit_parser import parse_time_input
# 支持格式: "5 min", "1 h", "300", "1.5 hours"
time_seconds = parse_time_input("5 min") # -> 300.0
time_seconds = parse_time_input(120) # -> 120.0
time_seconds = parse_time_input("1 h") # -> 3600.0
```
### 4. 参数验证
所有参数必须进行验证和类型转换:
```python
# 验证范围
if speed < 10.0 or speed > 1500.0:
logger.warning(f"速度 {speed} 超出范围,修正为 300")
speed = 300.0
# 类型转换
param = float(param) if not isinstance(param, (int, float)) else param
```
### 5. 日志记录
使用项目日志记录器:
```python
logger = logging.getLogger(__name__)
def generate_protocol(...):
logger.info(f"开始生成协议...")
logger.debug(f"参数: vessel={vessel_id}, time={time}")
logger.warning(f"参数修正: {old_value} -> {new_value}")
```
## 便捷函数
为常用操作提供便捷函数:
```python
def stir_briefly(G: nx.DiGraph, vessel: Union[str, dict],
speed: float = 300.0) -> List[Dict[str, Any]]:
"""短时间搅拌30秒"""
return generate_stir_protocol(G, vessel, time="30", stir_speed=speed)
def stir_vigorously(G: nx.DiGraph, vessel: Union[str, dict],
time: str = "5 min") -> List[Dict[str, Any]]:
"""剧烈搅拌"""
return generate_stir_protocol(G, vessel, time=time, stir_speed=800.0)
```
## 测试函数
每个协议文件应包含测试函数:
```python
def test_{operation}_protocol():
"""测试协议生成"""
# 测试参数处理
vessel_dict = {"id": "flask_1", "name": "反应瓶1"}
vessel_id = extract_vessel_id(vessel_dict)
assert vessel_id == "flask_1"
# 测试单位解析
time_s = parse_time_input("5 min")
assert time_s == 300.0
if __name__ == "__main__":
test_{operation}_protocol()
```
## 现有协议参考
- `stir_protocol.py` - 搅拌操作
- `add_protocol.py` - 添加物料
- `filter_protocol.py` - 过滤操作
- `heatchill_protocol.py` - 加热/冷却
- `separate_protocol.py` - 分离操作
- `evaporate_protocol.py` - 蒸发操作

View File

@@ -0,0 +1,319 @@
---
description: 注册表配置规范 (YAML)
globs: ["unilabos/registry/**/*.yaml"]
---
# 注册表配置规范
## 概述
注册表使用 YAML 格式定义设备和资源类型,是 Uni-Lab-OS 的核心配置系统。
## 目录结构
```
unilabos/registry/
├── devices/ # 设备类型注册
│ ├── virtual_device.yaml
│ ├── liquid_handler.yaml
│ └── ...
├── device_comms/ # 通信设备配置
│ ├── communication_devices.yaml
│ └── modbus_ioboard.yaml
└── resources/ # 资源类型注册
├── bioyond/
├── organic/
├── opentrons/
└── ...
```
## 设备注册表格式
### 基本结构
```yaml
device_type_id:
# 基本信息
description: "设备描述"
version: "1.0.0"
category:
- category_name
icon: "icon_device.webp"
# 类配置
class:
module: "unilabos.devices.my_module:MyClass"
type: python
# 状态类型(属性 -> ROS消息类型
status_types:
status: String
temperature: Float64
is_running: Bool
# 动作映射
action_value_mappings:
action_name:
type: UniLabJsonCommand # 或 UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
handles: {}
```
### action_value_mappings 详细格式
```yaml
action_value_mappings:
# 同步动作
my_sync_action:
type: UniLabJsonCommand
goal:
param1: param1
param2: param2
feedback: {}
result:
success: success
message: message
goal_default:
param1: 0.0
param2: ""
handles: {}
placeholder_keys:
device_param: unilabos_devices # 设备选择器
resource_param: unilabos_resources # 资源选择器
schema:
title: "动作名称参数"
description: "动作描述"
type: object
properties:
goal:
type: object
properties:
param1:
type: number
param2:
type: string
required:
- param1
feedback: {}
result:
type: object
properties:
success:
type: boolean
message:
type: string
required:
- goal
# 异步动作
my_async_action:
type: UniLabJsonCommandAsync
goal: {}
feedback:
progress: progress
current_status: status
result:
success: success
schema: {...}
```
### 自动生成的动作
以 `auto-` 开头的动作由系统自动生成:
```yaml
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
auto-cleanup:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
```
### handles 配置
用于工作流编辑器中的数据流连接:
```yaml
handles:
input:
- handler_key: "input_resource"
data_type: "resource"
label: "输入资源"
data_source: "handle"
data_key: "resources"
output:
- handler_key: "output_labware"
data_type: "resource"
label: "输出器皿"
data_source: "executor"
data_key: "created_resource.@flatten"
```
## 资源注册表格式
```yaml
resource_type_id:
description: "资源描述"
version: "1.0.0"
category:
- category_name
icon: ""
handles: []
init_param_schema: {}
class:
module: "unilabos.resources.my_module:MyResource"
type: pylabrobot # 或 python
```
### PyLabRobot 资源示例
```yaml
BIOYOND_Electrolyte_6VialCarrier:
category:
- bottle_carriers
- bioyond
class:
module: "unilabos.resources.bioyond.bottle_carriers:BIOYOND_Electrolyte_6VialCarrier"
type: pylabrobot
version: "1.0.0"
```
## 状态类型映射
Python 类型到 ROS 消息类型的映射:
| Python 类型 | ROS 消息类型 |
|------------|-------------|
| `str` | `String` |
| `bool` | `Bool` |
| `int` | `Int64` |
| `float` | `Float64` |
| `list` | `String` (序列化) |
| `dict` | `String` (序列化) |
## 自动完善注册表
使用 `--complete_registry` 参数自动生成 schema
```bash
python -m unilabos.app.main --complete_registry
```
这会:
1. 扫描设备类的方法签名
2. 自动生成 `auto-` 前缀的动作
3. 生成 JSON Schema
4. 更新 YAML 文件
## 验证规则
1. **device_type_id** 必须唯一
2. **module** 路径必须正确可导入
3. **status_types** 的类型必须是有效的 ROS 消息类型
4. **schema** 必须是有效的 JSON Schema
## 示例:完整设备配置
```yaml
virtual_stirrer:
category:
- virtual_device
description: "虚拟搅拌器设备"
version: "1.0.0"
icon: "icon_stirrer.webp"
handles: []
init_param_schema: {}
class:
module: "unilabos.devices.virtual.virtual_stirrer:VirtualStirrer"
type: python
status_types:
status: String
operation_mode: String
current_speed: Float64
is_stirring: Bool
remaining_time: Float64
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema:
title: "initialize参数"
type: object
properties:
goal:
type: object
properties: {}
feedback: {}
result: {}
required:
- goal
stir:
type: UniLabJsonCommandAsync
goal:
stir_time: stir_time
stir_speed: stir_speed
settling_time: settling_time
feedback:
current_speed: current_speed
remaining_time: remaining_time
result:
success: success
goal_default:
stir_time: 60.0
stir_speed: 300.0
settling_time: 30.0
handles: {}
schema:
title: "stir参数"
description: "搅拌操作"
type: object
properties:
goal:
type: object
properties:
stir_time:
type: number
description: "搅拌时间(秒)"
stir_speed:
type: number
description: "搅拌速度RPM"
settling_time:
type: number
description: "沉降时间(秒)"
required:
- stir_time
- stir_speed
feedback:
type: object
properties:
current_speed:
type: number
remaining_time:
type: number
result:
type: object
properties:
success:
type: boolean
required:
- goal
```

View File

@@ -0,0 +1,233 @@
---
description: ROS 2 集成开发规范
globs: ["unilabos/ros/**/*.py", "**/*_node.py"]
---
# ROS 2 集成开发规范
## 概述
Uni-Lab-OS 使用 ROS 2 作为设备通信中间件,基于 rclpy 实现。
## 核心组件
### BaseROS2DeviceNode
设备节点基类,提供:
- ROS Topic 自动发布(状态属性)
- Action Server 自动创建(设备动作)
- 资源管理服务
- 异步任务调度
```python
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
```
### 消息转换器
```python
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg_with_mapping,
msg_converter_manager,
ros_action_to_json_schema,
ros_message_to_json_schema,
)
```
## 设备与 ROS 集成
### post_init 方法
设备类必须实现 `post_init` 方法接收 ROS 节点:
```python
class MyDevice:
_ros_node: BaseROS2DeviceNode
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点注入"""
self._ros_node = ros_node
```
### 状态属性发布
设备的 `@property` 属性会自动发布为 ROS Topic
```python
class MyDevice:
@property
def temperature(self) -> float:
return self._temperature
# 自动发布到 /{namespace}/temperature Topic
```
### Topic 配置装饰器
```python
from unilabos.utils.decorator import topic_config
class MyDevice:
@property
@topic_config(period=1.0, print_publish=False, qos=10)
def fast_data(self) -> float:
"""高频数据 - 每秒发布一次"""
return self._fast_data
@property
@topic_config(period=5.0)
def slow_data(self) -> str:
"""低频数据 - 每5秒发布一次"""
return self._slow_data
```
### 订阅装饰器
```python
from unilabos.utils.decorator import subscribe
class MyDevice:
@subscribe(topic="/external/sensor_data", qos=10)
def on_sensor_data(self, msg):
"""订阅外部Topic"""
self._sensor_value = msg.data
```
## 异步操作
### 使用 ROS 节点睡眠
```python
# 推荐使用ROS节点的睡眠方法
await self._ros_node.sleep(1.0)
# 不推荐直接使用asyncio可能导致回调阻塞
await asyncio.sleep(1.0)
```
### 获取事件循环
```python
from unilabos.ros.x.rclpyx import get_event_loop
loop = get_event_loop()
```
## 消息类型
### unilabos_msgs 包
```python
from unilabos_msgs.msg import Resource
from unilabos_msgs.srv import (
ResourceAdd,
ResourceDelete,
ResourceUpdate,
ResourceList,
SerialCommand,
)
from unilabos_msgs.action import SendCmd
```
### Resource 消息结构
```python
Resource:
id: str
name: str
category: str
type: str
parent: str
children: List[str]
config: str # JSON字符串
data: str # JSON字符串
sample_id: str
pose: Pose
```
## 日志适配器
```python
from unilabos.utils.log import info, debug, warning, error, trace
class MyDevice:
def __init__(self):
# 创建设备专属日志器
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
```
ROSLoggerAdapter 同时向自定义日志和 ROS 日志发送消息。
## Action Server
设备动作自动创建为 ROS Action Server
```yaml
# 在注册表中配置
action_value_mappings:
my_action:
type: UniLabJsonCommandAsync # 异步Action
goal: {...}
feedback: {...}
result: {...}
```
### Action 类型
- **UniLabJsonCommand**: 同步动作
- **UniLabJsonCommandAsync**: 异步动作支持feedback
## 服务客户端
```python
from rclpy.client import Client
# 调用其他节点的服务
response = await self._ros_node.call_service(
service_name="/other_node/service",
request=MyServiceRequest(...)
)
```
## 命名空间
设备节点使用命名空间隔离:
```
/{device_id}/ # 设备命名空间
/{device_id}/status # 状态Topic
/{device_id}/temperature # 温度Topic
/{device_id}/my_action # 动作Server
```
## 调试
### 查看 Topic
```bash
ros2 topic list
ros2 topic echo /{device_id}/status
```
### 查看 Action
```bash
ros2 action list
ros2 action info /{device_id}/my_action
```
### 查看 Service
```bash
ros2 service list
ros2 service call /{device_id}/resource_list unilabos_msgs/srv/ResourceList
```
## 最佳实践
1. **状态属性命名**: 使用蛇形命名法snake_case
2. **Topic 频率**: 根据数据变化频率调整,避免过高频率
3. **Action 反馈**: 长时间操作提供进度反馈
4. **错误处理**: 使用 try-except 捕获并记录错误
5. **资源清理**: 在 cleanup 方法中正确清理资源

View File

@@ -0,0 +1,357 @@
---
description: 测试开发规范
globs: ["tests/**/*.py", "**/test_*.py"]
---
# 测试开发规范
## 目录结构
```
tests/
├── __init__.py
├── devices/ # 设备测试
│ └── liquid_handling/
│ └── test_transfer_liquid.py
├── resources/ # 资源测试
│ ├── test_bottle_carrier.py
│ └── test_resourcetreeset.py
├── ros/ # ROS消息测试
│ └── msgs/
│ ├── test_basic.py
│ ├── test_conversion.py
│ └── test_mapping.py
└── workflow/ # 工作流测试
└── merge_workflow.py
```
## 测试框架
使用 pytest 作为测试框架:
```bash
# 运行所有测试
pytest tests/
# 运行特定测试文件
pytest tests/resources/test_bottle_carrier.py
# 运行特定测试函数
pytest tests/resources/test_bottle_carrier.py::test_bottle_carrier
# 显示详细输出
pytest -v tests/
# 显示打印输出
pytest -s tests/
```
## 测试文件模板
```python
import pytest
from typing import List, Dict, Any
# 导入被测试的模块
from unilabos.resources.bioyond.bottle_carriers import (
BIOYOND_Electrolyte_6VialCarrier,
)
from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Solid_Vial,
)
class TestBottleCarrier:
"""BottleCarrier 测试类"""
def setup_method(self):
"""每个测试方法前执行"""
self.carrier = BIOYOND_Electrolyte_6VialCarrier("test_carrier")
def teardown_method(self):
"""每个测试方法后执行"""
pass
def test_carrier_creation(self):
"""测试载架创建"""
assert self.carrier.name == "test_carrier"
assert len(self.carrier.sites) == 6
def test_bottle_placement(self):
"""测试瓶子放置"""
bottle = BIOYOND_PolymerStation_Solid_Vial("test_bottle")
# 测试逻辑...
assert bottle.name == "test_bottle"
def test_standalone_function():
"""独立测试函数"""
result = some_function()
assert result is True
# 参数化测试
@pytest.mark.parametrize("input,expected", [
("5 min", 300.0),
("1 h", 3600.0),
("120", 120.0),
(60, 60.0),
])
def test_time_parsing(input, expected):
"""测试时间解析"""
from unilabos.compile.utils.unit_parser import parse_time_input
assert parse_time_input(input) == expected
# 异常测试
def test_invalid_input_raises_error():
"""测试无效输入抛出异常"""
with pytest.raises(ValueError) as exc_info:
invalid_function("bad_input")
assert "invalid" in str(exc_info.value).lower()
# 跳过条件测试
@pytest.mark.skipif(
not os.environ.get("ROS_DISTRO"),
reason="需要ROS环境"
)
def test_ros_feature():
"""需要ROS环境的测试"""
pass
```
## 设备测试
### 虚拟设备测试
```python
import pytest
import asyncio
from unittest.mock import MagicMock, AsyncMock
from unilabos.devices.virtual.virtual_stirrer import VirtualStirrer
class TestVirtualStirrer:
"""VirtualStirrer 测试"""
@pytest.fixture
def stirrer(self):
"""创建测试用搅拌器"""
device = VirtualStirrer(
device_id="test_stirrer",
config={"max_speed": 1500.0, "min_speed": 50.0}
)
# Mock ROS节点
mock_node = MagicMock()
mock_node.sleep = AsyncMock(return_value=None)
device.post_init(mock_node)
return device
@pytest.mark.asyncio
async def test_initialize(self, stirrer):
"""测试初始化"""
result = await stirrer.initialize()
assert result is True
assert stirrer.status == "待机中"
@pytest.mark.asyncio
async def test_stir_action(self, stirrer):
"""测试搅拌动作"""
await stirrer.initialize()
result = await stirrer.stir(
stir_time=5.0,
stir_speed=300.0,
settling_time=2.0
)
assert result is True
assert stirrer.operation_mode == "Completed"
@pytest.mark.asyncio
async def test_stir_invalid_speed(self, stirrer):
"""测试无效速度"""
await stirrer.initialize()
# 速度超出范围
result = await stirrer.stir(
stir_time=5.0,
stir_speed=2000.0, # 超过max_speed
settling_time=0.0
)
assert result is False
assert "错误" in stirrer.status
```
### 异步测试配置
```python
# conftest.py
import pytest
import asyncio
@pytest.fixture(scope="session")
def event_loop():
"""创建事件循环"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
```
## 资源测试
```python
import pytest
from unilabos.resources.resource_tracker import (
ResourceTreeSet,
ResourceTreeInstance,
)
def test_resource_tree_creation():
"""测试资源树创建"""
tree_set = ResourceTreeSet()
# 添加资源
resource = {"id": "res_1", "name": "Resource 1"}
tree_set.add_resource(resource)
# 验证
assert len(tree_set.all_nodes) == 1
assert tree_set.get_resource("res_1") is not None
def test_resource_tree_merge():
"""测试资源树合并"""
local_set = ResourceTreeSet()
remote_set = ResourceTreeSet()
# 设置数据...
local_set.merge_remote_resources(remote_set)
# 验证合并结果...
```
## ROS 消息测试
```python
import pytest
from unilabos.ros.msgs.message_converter import (
convert_to_ros_msg,
convert_from_ros_msg_with_mapping,
msg_converter_manager,
)
def test_message_conversion():
"""测试消息转换"""
# Python -> ROS
python_data = {"id": "test", "value": 42}
ros_msg = convert_to_ros_msg(python_data, MyMsgType)
assert ros_msg.id == "test"
assert ros_msg.value == 42
# ROS -> Python
result = convert_from_ros_msg_with_mapping(ros_msg, mapping)
assert result["id"] == "test"
```
## 协议测试
```python
import pytest
import networkx as nx
from unilabos.compile.stir_protocol import (
generate_stir_protocol,
extract_vessel_id,
)
@pytest.fixture
def topology_graph():
"""创建测试拓扑图"""
G = nx.DiGraph()
G.add_node("flask_1", **{"class": "flask"})
G.add_node("stirrer_1", **{"class": "virtual_stirrer"})
G.add_edge("stirrer_1", "flask_1")
return G
def test_generate_stir_protocol(topology_graph):
"""测试搅拌协议生成"""
actions = generate_stir_protocol(
G=topology_graph,
vessel="flask_1",
time="5 min",
stir_speed=300.0
)
assert len(actions) == 1
assert actions[0]["device_id"] == "stirrer_1"
assert actions[0]["action_name"] == "stir"
def test_extract_vessel_id():
"""测试vessel_id提取"""
# 字典格式
assert extract_vessel_id({"id": "flask_1"}) == "flask_1"
# 字符串格式
assert extract_vessel_id("flask_2") == "flask_2"
# 空值
assert extract_vessel_id("") == ""
```
## 测试标记
```python
# 慢速测试
@pytest.mark.slow
def test_long_running():
pass
# 需要网络
@pytest.mark.network
def test_network_call():
pass
# 需要ROS
@pytest.mark.ros
def test_ros_feature():
pass
```
运行特定标记的测试:
```bash
pytest -m "not slow" # 排除慢速测试
pytest -m ros # 仅ROS测试
```
## 覆盖率
```bash
# 生成覆盖率报告
pytest --cov=unilabos tests/
# HTML报告
pytest --cov=unilabos --cov-report=html tests/
```
## 最佳实践
1. **测试命名**: `test_{功能}_{场景}_{预期结果}`
2. **独立性**: 每个测试独立运行,不依赖其他测试
3. **Mock外部依赖**: 使用 unittest.mock 模拟外部服务
4. **参数化**: 使用 `@pytest.mark.parametrize` 减少重复代码
5. **fixtures**: 使用 fixtures 共享测试设置
6. **断言清晰**: 每个断言只验证一件事

View File

@@ -0,0 +1,353 @@
---
description: Uni-Lab-OS 实验室自动化平台开发规范 - 核心规则
globs: ["**/*.py", "**/*.yaml", "**/*.json"]
---
# Uni-Lab-OS 项目开发规范
## 项目概述
Uni-Lab-OS 是一个实验室自动化操作系统,用于连接和控制各种实验设备,实现实验工作流的自动化和标准化。
## 技术栈
- **Python 3.11** - 核心开发语言
- **ROS 2** - 设备通信中间件 (rclpy)
- **Conda/Mamba** - 包管理 (robostack-staging, conda-forge)
- **FastAPI** - Web API 服务
- **WebSocket** - 实时通信
- **NetworkX** - 拓扑图管理
- **YAML** - 配置和注册表定义
- **PyLabRobot** - 实验室自动化库集成
- **pytest** - 测试框架
- **asyncio** - 异步编程
## 项目结构
```
unilabos/
├── app/ # 应用入口、Web服务、后端
├── compile/ # 协议编译器 (stir, add, filter 等)
├── config/ # 配置管理
├── devices/ # 设备驱动 (真实/虚拟)
├── device_comms/ # 设备通信协议
├── device_mesh/ # 3D网格和可视化
├── registry/ # 设备和资源类型注册表 (YAML)
├── resources/ # 资源定义
├── ros/ # ROS 2 集成
├── utils/ # 工具函数
└── workflow/ # 工作流管理
```
## 代码规范
### Python 风格
1. **类型注解**:所有函数必须使用类型注解
```python
def transfer_liquid(
source: str,
destination: str,
volume: float,
**kwargs
) -> List[Dict[str, Any]]:
```
2. **Docstring**:使用 Google 风格的文档字符串
```python
def initialize(self) -> bool:
"""
初始化设备
Returns:
bool: 初始化是否成功
"""
```
3. **导入顺序**
- 标准库
- 第三方库
- ROS 相关 (rclpy, unilabos_msgs)
- 项目内部模块
### 异步编程
1. 设备操作方法使用 `async def`
2. 使用 `await self._ros_node.sleep()` 而非 `asyncio.sleep()`
3. 长时间运行操作需提供进度反馈
```python
async def stir(self, stir_time: float, stir_speed: float, **kwargs) -> bool:
"""执行搅拌操作"""
start_time = time_module.time()
while True:
elapsed = time_module.time() - start_time
remaining = max(0, stir_time - elapsed)
self.data.update({
"remaining_time": remaining,
"status": f"搅拌中: {stir_speed} RPM"
})
if remaining <= 0:
break
await self._ros_node.sleep(1.0)
return True
```
### 日志规范
使用项目自定义日志系统:
```python
from unilabos.utils.log import logger, info, debug, warning, error, trace
# 在设备类中使用
self.logger = logging.getLogger(f"DeviceName.{self.device_id}")
self.logger.info("设备初始化完成")
```
## 设备驱动开发
### 设备类结构
```python
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class MyDevice:
"""设备驱动类"""
_ros_node: BaseROS2DeviceNode
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.data = {} # 设备状态数据
def post_init(self, ros_node: BaseROS2DeviceNode):
"""ROS节点注入"""
self._ros_node = ros_node
async def initialize(self) -> bool:
"""初始化设备"""
pass
async def cleanup(self) -> bool:
"""清理设备"""
pass
# 状态属性 - 自动发布为 ROS Topic
@property
def status(self) -> str:
return self.data.get("status", "待机")
```
### 状态属性装饰器
```python
from unilabos.utils.decorator import topic_config
class MyDevice:
@property
@topic_config(period=1.0, qos=10) # 每秒发布一次
def temperature(self) -> float:
return self._temperature
```
### 虚拟设备
虚拟设备放置在 `unilabos/devices/virtual/` 目录下,命名为 `virtual_*.py`
## 注册表配置
### 设备注册表 (YAML)
位置: `unilabos/registry/devices/*.yaml`
```yaml
my_device_type:
category:
- my_category
description: "设备描述"
version: "1.0.0"
class:
module: "unilabos.devices.my_device:MyDevice"
type: python
status_types:
status: String
temperature: Float64
action_value_mappings:
auto-initialize:
type: UniLabJsonCommandAsync
goal: {}
feedback: {}
result: {}
schema: {...}
```
### 资源注册表 (YAML)
位置: `unilabos/registry/resources/**/*.yaml`
```yaml
my_container:
category:
- container
class:
module: "unilabos.resources.my_resource:MyContainer"
type: pylabrobot
version: "1.0.0"
```
## 协议编译器
位置: `unilabos/compile/*_protocol.py`
### 协议生成函数模板
```python
from typing import List, Dict, Any, Union
import networkx as nx
def generate_my_protocol(
G: nx.DiGraph,
vessel: Union[str, dict],
param1: float = 0.0,
**kwargs
) -> List[Dict[str, Any]]:
"""
生成操作协议序列
Args:
G: 物理拓扑图
vessel: 容器ID或字典
param1: 参数1
Returns:
List[Dict]: 动作序列
"""
# 提取vessel_id
vessel_id = vessel if isinstance(vessel, str) else vessel.get("id", "")
# 查找设备
device_id = find_connected_device(G, vessel_id)
# 生成动作
action_sequence = [{
"device_id": device_id,
"action_name": "my_action",
"action_kwargs": {
"vessel": {"id": vessel_id},
"param1": float(param1)
}
}]
return action_sequence
```
## 测试规范
### 测试文件位置
- 单元测试: `tests/` 目录
- 设备测试: `tests/devices/`
- 资源测试: `tests/resources/`
- ROS消息测试: `tests/ros/msgs/`
### 测试命名
```python
# tests/devices/my_device/test_my_device.py
import pytest
def test_device_initialization():
"""测试设备初始化"""
pass
def test_device_action():
"""测试设备动作"""
pass
```
## 错误处理
```python
from unilabos.utils.exception import UniLabException
try:
result = await device.execute_action()
except ValueError as e:
self.logger.error(f"参数错误: {e}")
self.data["status"] = "错误: 参数无效"
return False
except Exception as e:
self.logger.error(f"执行失败: {e}")
raise
```
## 配置管理
```python
from unilabos.config.config import BasicConfig, HTTPConfig
# 读取配置
port = BasicConfig.port
is_host = BasicConfig.is_host_mode
# 配置文件: local_config.py
```
## 常用工具
### 单例模式
```python
from unilabos.utils.decorator import singleton
@singleton
class MyManager:
pass
```
### 类型检查
```python
from unilabos.utils.type_check import NoAliasDumper
yaml.dump(data, f, Dumper=NoAliasDumper)
```
### 导入管理
```python
from unilabos.utils.import_manager import get_class
device_class = get_class("unilabos.devices.my_device:MyDevice")
```
## Git 提交规范
提交信息格式:
```
<type>(<scope>): <subject>
<body>
```
类型:
- `feat`: 新功能
- `fix`: 修复bug
- `docs`: 文档更新
- `refactor`: 重构
- `test`: 测试相关
- `chore`: 构建/工具相关
示例:
```
feat(devices): 添加虚拟搅拌器设备
- 实现VirtualStirrer类
- 支持定时搅拌和持续搅拌模式
- 添加速度验证逻辑
```

View File

@@ -0,0 +1,160 @@
---
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>/` 目录下

View File

@@ -0,0 +1,351 @@
---
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 9300Deck 通过 `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` |

View File

@@ -0,0 +1,292 @@
# 资源高级参考
本文件是 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` |

View File

@@ -0,0 +1,626 @@
---
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 "{}"
```
### 模板 BProtocol 工作站
直接使用 `ProtocolNode`,通常不需要自定义驱动类:
```python
from unilabos.devices.workstation.workstation_base import ProtocolNode
```
在图文件中配置 `protocol_type` 即可。
---
## 子设备访问sub_devices
工站初始化子设备后,所有子设备实例存储在 `self._ros_node.sub_devices` 字典中key 为设备 idvalue 为 `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"`)。
### 示例 1name="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/` 目录下各工作站实现。

View File

@@ -0,0 +1,371 @@
# 工作站高级模式参考
本文件是 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)
```

View File

@@ -0,0 +1,261 @@
---
name: batch-insert-reagent
description: Batch insert reagents into Uni-Lab platform — add chemicals with CAS, SMILES, supplier info. Use when the user wants to add reagents, insert chemicals, batch register reagents, or mentions 录入试剂/添加试剂/试剂入库/reagent.
---
# 批量录入试剂 Skill
通过云端 API 批量录入试剂信息,支持逐条或批量操作。
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
# 方式二:手动计算
# base64(ak:sk) → Authorization: Lab <token>
```
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <gen_auth.py 输出的 token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
## 请求约定
所有请求使用 `curl -s`POST 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
### 2. 录入试剂
```bash
curl -s -X POST "$BASE/api/v1/lab/reagent" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{
"lab_uuid": "<lab_uuid>",
"cas": "<CAS号>",
"name": "<试剂名称>",
"molecular_formula": "<分子式>",
"smiles": "<SMILES>",
"stock_in_quantity": <入库数量>,
"unit": "<单位字符串>",
"supplier": "<供应商>",
"production_date": "<生产日期 ISO 8601>",
"expiry_date": "<过期日期 ISO 8601>"
}'
```
返回成功时包含试剂 UUID
```json
{"code": 0, "data": {"uuid": "xxx", ...}}
```
---
## 试剂字段说明
| 字段 | 类型 | 必填 | 说明 | 示例 |
| ------------------- | ------ | ---- | ----------------------------- | ------------------------ |
| `lab_uuid` | string | 是 | 实验室 UUID从 API #1 获取) | `"8511c672-..."` |
| `cas` | string | 是 | CAS 注册号 | `"7732-18-3"` |
| `name` | string | 是 | 试剂中文/英文名称 | `"水"` |
| `molecular_formula` | string | 是 | 分子式 | `"H2O"` |
| `smiles` | string | 是 | SMILES 表示 | `"O"` |
| `stock_in_quantity` | number | 是 | 入库数量 | `10` |
| `unit` | string | 是 | 单位(字符串,见下表) | `"mL"` |
| `supplier` | string | 否 | 供应商名称 | `"国药集团"` |
| `production_date` | string | 否 | 生产日期ISO 8601 | `"2025-11-18T00:00:00Z"` |
| `expiry_date` | string | 否 | 过期日期ISO 8601 | `"2026-11-18T00:00:00Z"` |
### unit 单位值
| 值 | 单位 |
| ------ | ---- |
| `"mL"` | 毫升 |
| `"L"` | 升 |
| `"g"` | 克 |
| `"kg"` | 千克 |
| `"瓶"` | 瓶 |
> 根据试剂状态选择:液体用 `"mL"` / `"L"`,固体用 `"g"` / `"kg"`。
---
## 批量录入策略
### 方式一:用户提供 JSON 数组
用户一次性给出多条试剂数据:
```json
[
{
"cas": "7732-18-3",
"name": "水",
"molecular_formula": "H2O",
"smiles": "O",
"stock_in_quantity": 10,
"unit": "mL"
},
{
"cas": "64-17-5",
"name": "乙醇",
"molecular_formula": "C2H6O",
"smiles": "CCO",
"stock_in_quantity": 5,
"unit": "L"
}
]
```
Agent 自动为每条补充 `lab_uuid``production_date``expiry_date` 等字段后逐条提交。
Agent 循环调用 API #2 逐条录入,每条记录一次 API 调用。
### 方式二:用户逐个描述
用户口头描述试剂(如「帮我录入 500mL 的无水乙醇Sigma 的」agent 自行补全字段:
1. 根据名称查找 CAS 号、分子式、SMILES参考下方速查表或自行推断
2. 构建完整的请求体
3. 向用户确认后提交
### 方式三:从 CSV/表格批量导入
用户提供 CSV 或表格文件路径agent 读取并解析:
```bash
# 期望的 CSV 格式(首行为表头)
cas,name,molecular_formula,smiles,stock_in_quantity,unit,supplier,production_date,expiry_date
7732-18-3,水,H2O,O,10,mL,农夫山泉,2025-11-18T00:00:00Z,2026-11-18T00:00:00Z
```
### 日期格式规则(重要)
所有日期字段(`production_date``expiry_date`**必须**使用 ISO 8601 完整格式:`YYYY-MM-DDTHH:MM:SSZ`
- 用户输入 `2025-03-01` → 转换为 `"2025-03-01T00:00:00Z"`
- 用户输入 `2025/9/1` → 转换为 `"2025-09-01T00:00:00Z"`
- 用户未提供日期 → 使用当天日期 + `T00:00:00Z`,有效期默认 +1 年
**禁止**发送不带时间部分的日期字符串(如 `"2025-03-01"`API 会拒绝。
### 执行与汇报
每次 API 调用后:
1. 检查返回 `code`0 = 成功)
2. 记录成功/失败数量
3. 全部完成后汇总:「共录入 N 条试剂,成功 X 条,失败 Y 条」
4. 如有失败,列出失败的试剂名称和错误信息
---
## 常见试剂速查表
| 名称 | CAS | 分子式 | SMILES |
| --------------------- | --------- | ---------- | ------------------------------------ |
| 水 | 7732-18-3 | H2O | O |
| 乙醇 | 64-17-5 | C2H6O | CCO |
| 乙酸 | 64-19-7 | C2H4O2 | CC(O)=O |
| 甲醇 | 67-56-1 | CH4O | CO |
| 丙酮 | 67-64-1 | C3H6O | CC(C)=O |
| 二甲基亚砜(DMSO) | 67-68-5 | C2H6OS | CS(C)=O |
| 乙酸乙酯 | 141-78-6 | C4H8O2 | CCOC(C)=O |
| 二氯甲烷 | 75-09-2 | CH2Cl2 | ClCCl |
| 四氢呋喃(THF) | 109-99-9 | C4H8O | C1CCOC1 |
| N,N-二甲基甲酰胺(DMF) | 68-12-2 | C3H7NO | CN(C)C=O |
| 氯仿 | 67-66-3 | CHCl3 | ClC(Cl)Cl |
| 乙腈 | 75-05-8 | C2H3N | CC#N |
| 甲苯 | 108-88-3 | C7H8 | Cc1ccccc1 |
| 正己烷 | 110-54-3 | C6H14 | CCCCCC |
| 异丙醇 | 67-63-0 | C3H8O | CC(C)O |
| 盐酸 | 7647-01-0 | HCl | Cl |
| 硫酸 | 7664-93-9 | H2SO4 | OS(O)(=O)=O |
| 氢氧化钠 | 1310-73-2 | NaOH | [Na]O |
| 碳酸钠 | 497-19-8 | Na2CO3 | [Na]OC([O-])=O.[Na+] |
| 氯化钠 | 7647-14-5 | NaCl | [Na]Cl |
| 乙二胺四乙酸(EDTA) | 60-00-4 | C10H16N2O8 | OC(=O)CN(CCN(CC(O)=O)CC(O)=O)CC(O)=O |
> 此表仅供快速参考。对于不在表中的试剂agent 应根据化学知识推断或提示用户补充。
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: 收集试剂信息(用户提供列表/逐个描述/CSV文件
- [ ] Step 5: 补全缺失字段CAS、分子式、SMILES 等)
- [ ] Step 6: 向用户确认待录入的试剂列表
- [ ] Step 7: 循环调用 POST /lab/reagent 逐条录入(每条需含 lab_uuid
- [ ] Step 8: 汇总结果(成功/失败数量及详情)
```
---
## 完整示例
用户说:「帮我录入 3 种试剂500mL 无水乙醇、1kg 氯化钠、2L 去离子水」
Agent 构建的请求序列:
```json
// 第 1 条
{"lab_uuid": "8511c672-...", "cas": "64-17-5", "name": "无水乙醇", "molecular_formula": "C2H6O", "smiles": "CCO", "stock_in_quantity": 500, "unit": "mL", "supplier": "国药集团", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
// 第 2 条
{"lab_uuid": "8511c672-...", "cas": "7647-14-5", "name": "氯化钠", "molecular_formula": "NaCl", "smiles": "[Na]Cl", "stock_in_quantity": 1, "unit": "kg", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
// 第 3 条
{"lab_uuid": "8511c672-...", "cas": "7732-18-3", "name": "去离子水", "molecular_formula": "H2O", "smiles": "O", "stock_in_quantity": 2, "unit": "L", "supplier": "", "production_date": "2025-01-01T00:00:00Z", "expiry_date": "2026-01-01T00:00:00Z"}
```

View File

@@ -0,0 +1,360 @@
---
name: batch-submit-experiment
description: Batch submit experiments (notebooks) to the Uni-Lab cloud platform (leap-lab) — list workflows, generate node_params from registry schemas, submit multiple rounds, check notebook status. Use when the user wants to submit experiments, create notebooks, batch run workflows, check experiment status, or mentions 提交实验/批量实验/notebook/实验轮次/实验状态.
---
# Uni-Lab 批量提交实验指南
通过 Uni-Lab 云端 API 批量提交实验notebook支持多轮实验参数配置。根据 workflow 模板详情和本地设备注册表自动生成 `node_params` 模板。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token任选一种方式
```bash
# 方式一Python 一行生成注意scheme 是 "Lab" 不是 "Basic"
python -c "import base64,sys; print('Authorization: Lab ' + base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
# 方式二:手动计算
# base64(ak:sk) → Authorization: Lab <token>
# ⚠️ 这里的 "Lab" 是 Uni-Lab 平台的 auth scheme绝对不能用 "Basic" 替代
```
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
# ⚠️ Auth scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
### 3. req_device_registry_upload.json设备注册表
**批量提交实验时需要本地注册表来解析 workflow 节点的参数 schema。**
**必须先用 Glob 工具搜索文件**,不要直接猜测路径:
```
Glob: **/req_device_registry_upload.json
```
常见位置(仅供参考,以 Glob 实际结果为准):
- `<workspace>/unilabos_data/req_device_registry_upload.json`
- `<workspace>/req_device_registry_upload.json`
找到后**检查文件修改时间**并告知用户。超过 1 天提醒用户是否需要重新启动 `unilab`
**如果 Glob 搜索无结果** → 告知用户先运行 `unilab` 启动命令,等注册表生成后再执行。可跳过此步,但将无法自动生成参数模板,需要用户手动填写 `param`
### 4. workflow_uuid目标工作流
用户需要提供要提交的 workflow UUID。如果用户不确定通过 API #3 列出可用 workflow 供选择。
**四项全部就绪后才可开始。**
## Session State
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `project_uuid` — 项目 UUID通过 API #2 列出项目列表,**让用户选择**
- `workflow_uuid` — 工作流 UUID用户提供或从列表选择
- `workflow_nodes` — workflow 中各 action 节点的 uuid、设备 ID、动作名从 API #4 获取)
## 请求约定
所有请求使用 `curl -s`POST 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
>
> **PowerShell JSON 传参**PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则会被解析为 splatting 运算符)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
### 2. 列出实验室项目(让用户选择项目)
```bash
curl -s -X GET "$BASE/api/v1/lab/project/list?lab_uuid=$lab_uuid" -H "$AUTH"
```
返回:
```json
{
"code": 0,
"data": {
"items": [
{
"uuid": "1b3f249a-...",
"name": "bt",
"description": null,
"status": "active",
"created_at": "2026-04-09T14:31:28+08:00"
},
{
"uuid": "b6366243-...",
"name": "default",
"description": "默认项目",
"status": "active",
"created_at": "2026-03-26T11:13:36+08:00"
}
]
}
}
```
展示 `data.items[]` 中每个项目的 `name``uuid`,让用户选择。用户**必须**选择一个项目,记住 `project_uuid`(即选中项目的 `uuid`),后续创建 notebook 时需要提供。
### 3. 列出可用 workflow
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/workflows?page=1&page_size=20&lab_uuid=$lab_uuid" -H "$AUTH"
```
返回 workflow 列表,展示给用户选择。列出每个 workflow 的 `uuid``name`
### 4. 获取 workflow 模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
返回 workflow 的完整结构,包含所有 action 节点信息。需要从响应中提取:
- 每个 action 节点的 `node_uuid`
- 每个节点对应的设备 ID`resource_template_name`
- 每个节点的动作名(`node_template_name`
- 每个节点的现有参数(`param`
> **注意**:此 API 返回格式可能因版本不同而有差异。首次调用时,先打印完整响应分析结构,再提取节点信息。常见的节点字段路径为 `data.nodes[]` 或 `data.workflow_nodes[]`。
### 5. 提交实验(创建 notebook
```bash
curl -s -X POST "$BASE/api/v1/lab/notebook" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体结构:
```json
{
"lab_uuid": "<lab_uuid>",
"project_uuid": "<project_uuid>",
"workflow_uuid": "<workflow_uuid>",
"name": "<实验名称>",
"node_params": [
{
"sample_uuids": ["<样品UUID1>", "<样品UUID2>"],
"datas": [
{
"node_uuid": "<workflow中的节点UUID>",
"param": {},
"sample_params": [
{
"container_uuid": "<容器UUID>",
"sample_value": {
"liquid_names": "<液体名称>",
"volumes": 1000
}
}
]
}
]
}
]
}
```
> **注意**`sample_uuids` 必须是 **UUID 数组**`[]uuid.UUID`),不是字符串。无样品时传空数组 `[]`。
### 6. 查询 notebook 状态
提交成功后,使用返回的 notebook UUID 查询执行状态:
```bash
curl -s -X GET "$BASE/api/v1/lab/notebook/status?uuid=$notebook_uuid" -H "$AUTH"
```
提交后应**立即查询一次**状态,确认 notebook 已被正确接收并开始调度。
---
## Notebook 请求体详解
### node_params 结构
`node_params` 是一个数组,**每个元素代表一轮实验**
- 要跑 2 轮 → `node_params` 有 2 个元素
- 要跑 N 轮 → `node_params` 有 N 个元素
### 每轮的字段
| 字段 | 类型 | 说明 |
| -------------- | ------------- | ----------------------------------------- |
| `sample_uuids` | array\<uuid\> | 该轮实验的样品 UUID 数组,无样品时传 `[]` |
| `datas` | array | 该轮中每个 workflow 节点的参数配置 |
### datas 中每个节点
| 字段 | 类型 | 说明 |
| --------------- | ------ | -------------------------------------------- |
| `node_uuid` | string | workflow 模板中的节点 UUID从 API #4 获取) |
| `param` | object | 动作参数(根据本地注册表 schema 填写) |
| `sample_params` | array | 样品相关参数(液体名、体积等) |
### sample_params 中每条
| 字段 | 类型 | 说明 |
| ---------------- | ------ | ---------------------------------------------------- |
| `container_uuid` | string | 容器 UUID |
| `sample_value` | object | 样品值,如 `{"liquid_names": "水", "volumes": 1000}` |
---
## 从本地注册表生成 param 模板
### 自动方式 — 运行脚本
```bash
python scripts/gen_notebook_params.py \
--auth <token> \
--base <BASE_URL> \
--workflow-uuid <workflow_uuid> \
[--registry <path/to/req_device_registry_upload.json>] \
[--rounds <轮次数>] \
[--output <输出文件路径>]
```
> 脚本位于本文档同级目录下的 `scripts/gen_notebook_params.py`。
脚本会:
1. 调用 workflow detail API 获取所有 action 节点
2. 读取本地注册表,为每个节点查找对应的 action schema
3. 生成 `notebook_template.json`,包含:
- 完整 `node_params` 骨架
- 每个节点的 param 字段及类型说明
- `_schema_info` 辅助信息(不提交,仅供参考)
### 手动方式
如果脚本不可用或注册表不存在:
1. 调用 API #4 获取 workflow 详情
2. 找到每个 action 节点的 `node_uuid`
3. 在本地注册表中查找对应设备的 `action_value_mappings`
```
resources[].id == <device_id>
→ resources[].class.action_value_mappings.<action_name>.schema.properties.goal.properties
```
4. 将 schema 中的 properties 作为 `param` 的字段模板
5. 按轮次复制 `node_params` 元素,让用户填写每轮的具体值
### 注册表结构参考
```json
{
"resources": [
{
"id": "liquid_handler.prcxi",
"class": {
"module": "unilabos.devices.xxx:ClassName",
"action_value_mappings": {
"transfer_liquid": {
"type": "LiquidHandlerTransfer",
"schema": {
"properties": {
"goal": {
"properties": {
"asp_vols": {
"type": "array",
"items": { "type": "number" }
},
"sources": { "type": "array" }
},
"required": ["asp_vols", "sources"]
}
}
},
"goal_default": {}
}
}
}
}
]
}
```
`param` 填写时,使用 `goal.properties` 中的字段名和类型。
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: GET /lab/project/list → 列出项目,让用户选择 → 获取 project_uuid
- [ ] Step 5: 确认 workflow_uuid用户提供或从 GET #3 列表选择)
- [ ] Step 6: GET workflow detail (#4) → 提取各节点 uuid、设备ID、动作名
- [ ] Step 7: 定位本地注册表 req_device_registry_upload.json
- [ ] Step 8: 运行 gen_notebook_params.py 或手动匹配 → 生成 node_params 模板
- [ ] Step 9: 引导用户填写每轮的参数sample_uuids、param、sample_params
- [ ] Step 10: 构建完整请求体(含 project_uuid→ POST /lab/notebook 提交
- [ ] Step 11: 检查返回结果,记录 notebook UUID
- [ ] Step 12: GET /lab/notebook/status → 查询 notebook 状态,确认已调度
```
---
## 常见问题
### Q: workflow 中有多个节点,每轮都要填所有节点的参数吗?
是的。`datas` 数组中需要包含该轮实验涉及的每个 workflow 节点的参数。通常每个 action 节点都需要一条 `datas` 记录。
### Q: 多轮实验的参数完全不同吗?
通常每轮的 `param`(设备动作参数)可能相同或相似,但 `sample_uuids` 和 `sample_params`(样品信息)每轮不同。脚本生成模板时会按轮次复制骨架,用户只需修改差异部分。
### Q: 如何获取 sample_uuids 和 container_uuid
这些 UUID 通常来自实验室的样品管理系统。向用户询问或从资源树API `GET /lab/material/download/$lab_uuid`)中查找。

View File

@@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""
从 workflow 模板详情 + 本地设备注册表生成 notebook 提交用的 node_params 模板。
用法:
python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]
选项:
--auth <token> Lab tokenbase64(ak:sk) 的结果,不含 "Lab " 前缀)
--base <url> API 基础 URL如 https://leap-lab.test.bohrium.com
--workflow-uuid <uuid> 目标 workflow 的 UUID
--registry <path> 本地注册表文件路径(默认自动搜索)
--rounds <n> 实验轮次数(默认 1
--output <path> 输出模板文件路径(默认 notebook_template.json
--dump-response 打印 workflow detail API 的原始响应(调试用)
示例:
python gen_notebook_params.py \\
--auth YTFmZDlkNGUtxxxx \\
--base https://leap-lab.test.bohrium.com \\
--workflow-uuid abc-123-def \\
--rounds 2
"""
import copy
import json
import os
import sys
from datetime import datetime
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
REGISTRY_FILENAME = "req_device_registry_upload.json"
def find_registry(explicit_path=None):
"""查找本地注册表文件,逻辑同 extract_device_actions.py"""
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 build_registry_index(registry_data):
"""构建 device_id → action_value_mappings 的索引"""
index = {}
for res in registry_data.get("resources", []):
rid = res.get("id", "")
avm = res.get("class", {}).get("action_value_mappings", {})
if rid and avm:
index[rid] = avm
return index
def flatten_goal_schema(action_data):
"""从 action_value_mappings 条目中提取 goal 层的 schema"""
schema = action_data.get("schema", {})
goal_schema = schema.get("properties", {}).get("goal", {})
return goal_schema if goal_schema else schema
def build_param_template(goal_schema):
"""根据 goal schema 生成 param 模板,含类型标注"""
properties = goal_schema.get("properties", {})
required = set(goal_schema.get("required", []))
template = {}
for field_name, field_def in properties.items():
if field_name == "unilabos_device_id":
continue
ftype = field_def.get("type", "any")
default = field_def.get("default")
if default is not None:
template[field_name] = default
elif ftype == "string":
template[field_name] = f"$TODO ({ftype}, {'required' if field_name in required else 'optional'})"
elif ftype == "number" or ftype == "integer":
template[field_name] = 0
elif ftype == "boolean":
template[field_name] = False
elif ftype == "array":
template[field_name] = []
elif ftype == "object":
template[field_name] = {}
else:
template[field_name] = f"$TODO ({ftype})"
return template
def fetch_workflow_detail(base_url, auth_token, workflow_uuid):
"""调用 workflow detail API"""
url = f"{base_url}/api/v1/lab/workflow/template/detail/{workflow_uuid}"
req = Request(url, method="GET")
req.add_header("Authorization", f"Lab {auth_token}")
try:
with urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
print(f"API 错误 {e.code}: {body}")
return None
except URLError as e:
print(f"网络错误: {e.reason}")
return None
def extract_nodes_from_response(response):
"""
从 workflow detail 响应中提取 action 节点列表。
适配多种可能的响应格式。
返回: [(node_uuid, resource_template_name, node_template_name, existing_param), ...]
"""
data = response.get("data", response)
search_keys = ["nodes", "workflow_nodes", "node_list", "steps"]
nodes_raw = None
for key in search_keys:
if key in data and isinstance(data[key], list):
nodes_raw = data[key]
break
if nodes_raw is None:
if isinstance(data, list):
nodes_raw = data
else:
for v in data.values():
if isinstance(v, list) and len(v) > 0 and isinstance(v[0], dict):
nodes_raw = v
break
if not nodes_raw:
print("警告: 未能从响应中提取节点列表")
print("响应顶层 keys:", list(data.keys()) if isinstance(data, dict) else type(data).__name__)
return []
result = []
for node in nodes_raw:
if not isinstance(node, dict):
continue
node_uuid = (
node.get("uuid")
or node.get("node_uuid")
or node.get("id")
or ""
)
resource_name = (
node.get("resource_template_name")
or node.get("device_id")
or node.get("resource_name")
or node.get("device_name")
or ""
)
template_name = (
node.get("node_template_name")
or node.get("action_name")
or node.get("template_name")
or node.get("action")
or node.get("name")
or ""
)
existing_param = node.get("param", {}) or {}
if node_uuid:
result.append((node_uuid, resource_name, template_name, existing_param))
return result
def generate_template(nodes, registry_index, rounds):
"""生成 notebook 提交模板"""
node_params = []
schema_info = {}
datas_template = []
for node_uuid, resource_name, template_name, existing_param in nodes:
param_template = {}
matched = False
if resource_name and template_name and resource_name in registry_index:
avm = registry_index[resource_name]
if template_name in avm:
goal_schema = flatten_goal_schema(avm[template_name])
param_template = build_param_template(goal_schema)
goal_default = avm[template_name].get("goal_default", {})
if goal_default:
for k, v in goal_default.items():
if k in param_template and v is not None:
param_template[k] = v
matched = True
schema_info[node_uuid] = {
"device_id": resource_name,
"action_name": template_name,
"action_type": avm[template_name].get("type", ""),
"schema_properties": list(goal_schema.get("properties", {}).keys()),
"required": goal_schema.get("required", []),
}
if not matched and existing_param:
param_template = existing_param
if not matched and not existing_param:
schema_info[node_uuid] = {
"device_id": resource_name,
"action_name": template_name,
"warning": "未在本地注册表中找到匹配的 action schema",
}
datas_template.append({
"node_uuid": node_uuid,
"param": param_template,
"sample_params": [
{
"container_uuid": "$TODO_CONTAINER_UUID",
"sample_value": {
"liquid_names": "$TODO_LIQUID_NAME",
"volumes": 0,
},
}
],
})
for i in range(rounds):
node_params.append({
"sample_uuids": f"$TODO_SAMPLE_UUID_ROUND_{i + 1}",
"datas": copy.deepcopy(datas_template),
})
return {
"lab_uuid": "$TODO_LAB_UUID",
"project_uuid": "$TODO_PROJECT_UUID",
"workflow_uuid": "$TODO_WORKFLOW_UUID",
"name": "$TODO_EXPERIMENT_NAME",
"node_params": node_params,
"_schema_info仅参考提交时删除": schema_info,
}
def parse_args(argv):
"""简单的参数解析"""
opts = {
"auth": None,
"base": None,
"workflow_uuid": None,
"registry": None,
"rounds": 1,
"output": "notebook_template.json",
"dump_response": False,
}
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--auth" and i + 1 < len(argv):
opts["auth"] = argv[i + 1]
i += 2
elif arg == "--base" and i + 1 < len(argv):
opts["base"] = argv[i + 1].rstrip("/")
i += 2
elif arg == "--workflow-uuid" and i + 1 < len(argv):
opts["workflow_uuid"] = argv[i + 1]
i += 2
elif arg == "--registry" and i + 1 < len(argv):
opts["registry"] = argv[i + 1]
i += 2
elif arg == "--rounds" and i + 1 < len(argv):
opts["rounds"] = int(argv[i + 1])
i += 2
elif arg == "--output" and i + 1 < len(argv):
opts["output"] = argv[i + 1]
i += 2
elif arg == "--dump-response":
opts["dump_response"] = True
i += 1
else:
print(f"未知参数: {arg}")
i += 1
return opts
def main():
opts = parse_args(sys.argv[1:])
if not opts["auth"] or not opts["base"] or not opts["workflow_uuid"]:
print("用法:")
print(" python gen_notebook_params.py --auth <token> --base <url> --workflow-uuid <uuid> [选项]")
print()
print("必需参数:")
print(" --auth <token> Lab tokenbase64(ak:sk)")
print(" --base <url> API 基础 URL")
print(" --workflow-uuid <uuid> 目标 workflow UUID")
print()
print("可选参数:")
print(" --registry <path> 注册表文件路径(默认自动搜索)")
print(" --rounds <n> 实验轮次数(默认 1")
print(" --output <path> 输出文件路径(默认 notebook_template.json")
print(" --dump-response 打印 API 原始响应")
sys.exit(1)
# 1. 查找并加载本地注册表
registry_path = find_registry(opts["registry"])
registry_index = {}
if registry_path:
mtime = os.path.getmtime(registry_path)
gen_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
print(f"注册表: {registry_path} (生成时间: {gen_time})")
registry_data = load_registry(registry_path)
registry_index = build_registry_index(registry_data)
print(f"已索引 {len(registry_index)} 个设备的 action schemas")
else:
print("警告: 未找到本地注册表,将跳过 param 模板生成")
print(" 提交时需要手动填写各节点的 param 字段")
# 2. 获取 workflow 详情
print(f"\n正在获取 workflow 详情: {opts['workflow_uuid']}")
response = fetch_workflow_detail(opts["base"], opts["auth"], opts["workflow_uuid"])
if not response:
print("错误: 无法获取 workflow 详情")
sys.exit(1)
if opts["dump_response"]:
print("\n=== API 原始响应 ===")
print(json.dumps(response, indent=2, ensure_ascii=False)[:5000])
print("=== 响应结束(截断至 5000 字符) ===\n")
# 3. 提取节点
nodes = extract_nodes_from_response(response)
if not nodes:
print("错误: 未能从 workflow 中提取任何 action 节点")
print("请使用 --dump-response 查看原始响应结构")
sys.exit(1)
print(f"\n找到 {len(nodes)} 个 action 节点:")
print(f" {'节点 UUID':<40} {'设备 ID':<30} {'动作名':<25} {'Schema'}")
print(" " + "-" * 110)
for node_uuid, resource_name, template_name, _ in nodes:
matched = "" if (resource_name in registry_index and
template_name in registry_index.get(resource_name, {})) else ""
print(f" {node_uuid:<40} {resource_name:<30} {template_name:<25} {matched}")
# 4. 生成模板
template = generate_template(nodes, registry_index, opts["rounds"])
template["workflow_uuid"] = opts["workflow_uuid"]
output_path = opts["output"]
with open(output_path, "w", encoding="utf-8") as f:
json.dump(template, f, indent=2, ensure_ascii=False)
print(f"\n模板已写入: {output_path}")
print(f" 轮次数: {opts['rounds']}")
print(f" 节点数/轮: {len(nodes)}")
print()
print("下一步:")
print(" 1. 打开模板文件,将 $TODO 占位符替换为实际值")
print(" 2. 删除 _schema_info 字段(仅供参考)")
print(" 3. 使用 POST /api/v1/lab/notebook 提交")
if __name__ == "__main__":
main()

View File

@@ -40,13 +40,13 @@ python ./scripts/gen_auth.py --config <config.py>
决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取: 决定 API 请求发往哪个服务器。从启动命令的 `--addr` 参数获取:
| `--addr` 值 | BASE URL | | `--addr` | BASE URL |
|-------------|----------| | -------------- | ----------------------------------- |
| `test` | `https://uni-lab.test.bohrium.com` | | `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://uni-lab.uat.bohrium.com` | | `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` | | `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://uni-lab.bohrium.com` | | 不传(默认) | `https://leap-lab.bohrium.com` |
| 其他自定义 URL | 直接使用该 URL | | 其他自定义 URL | 直接使用该 URL |
#### 必备项 ③req_device_registry_upload.json设备注册表 #### 必备项 ③req_device_registry_upload.json设备注册表
@@ -54,11 +54,11 @@ python ./scripts/gen_auth.py --config <config.py>
**推断 working_dir**(即 `unilabos_data` 所在目录): **推断 working_dir**(即 `unilabos_data` 所在目录):
| 条件 | working_dir 取值 | | 条件 | working_dir 取值 |
|------|------------------| | -------------------- | -------------------------------------------------------- |
| 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) | | 传了 `--working_dir` | `<working_dir>/unilabos_data/`(若子目录已存在则直接用) |
| 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` | | 仅传了 `--config` | `<config 文件所在目录>/unilabos_data/` |
| 都没传 | `<当前工作目录>/unilabos_data/` | | 都没传 | `<当前工作目录>/unilabos_data/` |
**按优先级搜索文件** **按优先级搜索文件**
@@ -84,24 +84,6 @@ python ./scripts/gen_auth.py --config <config.py>
python ./scripts/extract_device_actions.py --registry <找到的文件路径> 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。**
### Step 1 — 列出可用设备 ### Step 1 — 列出可用设备
@@ -129,6 +111,7 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。 脚本会显示设备的 Python 源码路径和类名,方便阅读源码了解参数含义。
每个 action 生成一个 JSON 文件,包含: 每个 action 生成一个 JSON 文件,包含:
- `type` — 作为 API 调用的 `action_type` - `type` — 作为 API 调用的 `action_type`
- `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义) - `schema` — 完整 JSON Schema`properties.goal.properties` 参数定义)
- `goal` — goal 字段映射(含占位符 `$placeholder` - `goal` — goal 字段映射(含占位符 `$placeholder`
@@ -136,13 +119,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
### Step 3 — 写 action-index.md ### Step 3 — 写 action-index.md
按模板为每个 action 写条目: 按模板为每个 action 写条目**必须包含 `action_type`**
```markdown ```markdown
### `<action_name>` ### `<action_name>`
<用途描述(一句话)> <用途描述(一句话)>
- **action_type**: `<从 actions/<name>.json 的 type 字段获取>`
- **Schema**: [`actions/<filename>.json`](actions/<filename>.json) - **Schema**: [`actions/<filename>.json`](actions/<filename>.json)
- **核心参数**: `param1`, `param2`(从 schema.required 获取) - **核心参数**: `param1`, `param2`(从 schema.required 获取)
- **可选参数**: `param3`, `param4` - **可选参数**: `param3`, `param4`
@@ -150,6 +134,8 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
``` ```
描述规则: 描述规则:
- **每个 action 必须标注 `action_type`**(从 JSON 的 `type` 字段读取),这是 API #9 调用时的必填参数,传错会导致任务永远卡住
-`schema.properties` 读参数列表schema 已提升为 goal 内容) -`schema.properties` 读参数列表schema 已提升为 goal 内容)
-`schema.required` 区分核心/可选参数 -`schema.required` 区分核心/可选参数
- 按功能分类(移液、枪头、外设等) - 按功能分类(移液、枪头、外设等)
@@ -158,12 +144,14 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- `unilabos_devices`**DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device - `unilabos_devices`**DeviceSlot**,填入路径字符串如 `"/host_node"`(从资源树筛选 type=device
- `unilabos_nodes`**NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点) - `unilabos_nodes`**NodeSlot**,填入路径字符串如 `"/PRCXI/PRCXI_Deck"`(资源树中任意节点)
- `unilabos_class`**ClassSlot**,填入类名字符串如 `"container"`(从注册表查找) - `unilabos_class`**ClassSlot**,填入类名字符串如 `"container"`(从注册表查找)
- `unilabos_formulation`**FormulationSlot**,填入配方数组 `[{well_name, liquids: [{name, volume}]}]`well_name 为目标物料的 name
- array 类型字段 → `[{id, name, uuid}, ...]` - array 类型字段 → `[{id, name, uuid}, ...]`
- 特殊:`create_resource``res_id`ResourceSlot可填不存在的路径 - 特殊:`create_resource``res_id`ResourceSlot可填不存在的路径
### Step 4 — 写 SKILL.md ### Step 4 — 写 SKILL.md
直接复用 `unilab-device-api` 的 API 模板10 个 endpoint,修改: 直接复用 `unilab-device-api` 的 API 模板,修改:
- 设备名称 - 设备名称
- Action 数量 - Action 数量
- 目录列表 - 目录列表
@@ -171,43 +159,96 @@ python ./scripts/extract_device_actions.py [--registry <path>] <device_id> ./ski
- **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key - **AUTH 头** — 使用 Step 0 中 `gen_auth.py` 生成的 `Authorization: Lab <token>`(不要硬编码 `Api` 类型的 key
- **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义 - **Python 源码路径** — 在 SKILL.md 开头注明设备对应的源码文件,方便参考参数含义
- **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot物料/设备/节点/类名) - **Slot 字段表** — 列出本设备哪些 action 的哪些字段需要填入 Slot物料/设备/节点/类名)
- **action_type 速查表** — 在 API #9 说明后面紧跟一个表格,列出每个 action 对应的 `action_type` 值(从 JSON `type` 字段提取),方便 agent 快速查找而无需打开 JSON 文件
API 模板结构: API 模板结构:
```markdown ```markdown
## 设备信息 ## 设备信息
- device_id, Python 源码路径, 设备类名 - device_id, Python 源码路径, 设备类名
## 前置条件(缺一不可) ## 前置条件(缺一不可)
- ak/sk → AUTH, --addr → BASE URL - ak/sk → AUTH, --addr → BASE URL
## Session State ## 请求约定
- lab_uuid通过 API #1 自动匹配,不要问用户), device_name
## API Endpoints (10 个) - Windows 平台必须用 curl.exe非 PowerShell 的 curl 别名)
# 注意:
# - #1 获取 lab 列表 + 自动匹配 lab_uuid遍历 is_admin 的 lab ## Session State
# 调用 /lab/info/{uuid} 比对 access_key == ak
# - #2 创建工作流用 POST /lab/workflow - lab_uuid通过 GET /edge/lab/info 直接获取,不要问用户), device_name
# - #10 获取资源树路径含 lab_uuid: /lab/material/download/{lab_uuid}
## API Endpoints
# - #1 GET /edge/lab/info → 直接拿到 lab_uuid
# - #2 创建工作流 POST /lab/workflow/owner → 拼 URL 告知用户
# - #3 创建节点 POST /edge/workflow/node
# body: {workflow_uuid, resource_template_name: "<device_id>", node_template_name: "<action_name>"}
# - #4 删除节点 DELETE /lab/workflow/nodes
# - #5 更新节点参数 PATCH /lab/workflow/node
# - #6 查询节点 handles POST /lab/workflow/node-handles
# body: {node_uuids: ["uuid1","uuid2"]} → 返回各节点的 handle_uuid
# - #7 批量创建边 POST /lab/workflow/edges
# body: {edges: [{source_node_uuid, target_node_uuid, source_handle_uuid, target_handle_uuid}]}
# - #8 启动工作流 POST /lab/workflow/{uuid}/run
# - #9 运行设备单动作 POST /lab/mcp/run/action action_type 必须从 action-index.md 或 actions/<name>.json 的 type 字段获取,传错会导致任务永远卡住)
# - #10 查询任务状态 GET /lab/mcp/task/{task_uuid}
# - #11 运行工作流单节点 POST /lab/mcp/run/workflow/action
# - #12 获取资源树 GET /lab/material/download/{lab_uuid}
# - #13 获取工作流模板详情 GET /lab/workflow/template/detail/{workflow_uuid}
# 返回 workflow 完整结构data.nodes[] 含每个节点的 uuid、name、param、device_name、handles
# - #14 按名称查询物料模板 GET /lab/material/template/by-name?lab_uuid=&name=
# 返回 res_template_uuid用于 #15 创建物料时的必填字段
# - #15 创建物料节点 POST /edge/material/node
# body: {res_template_uuid(从#14获取), name(自定义), display_name, parent_uuid?(从#12获取), ...}
# - #16 更新物料节点 PUT /edge/material/node
# body: {uuid(从#12获取), display_name?, description?, init_param_data?, data?, ...}
## Placeholder Slot 填写规则 ## Placeholder Slot 填写规则
- unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"} - unilabos_resources → ResourceSlot → {"id":"/path/name","name":"name","uuid":"xxx"}
- unilabos_devices → DeviceSlot → "/parent/device" 路径字符串 - unilabos_devices → DeviceSlot → "/parent/device" 路径字符串
- unilabos_nodes → NodeSlot → "/parent/node" 路径字符串 - unilabos_nodes → NodeSlot → "/parent/node" 路径字符串
- unilabos_class → ClassSlot → "class_name" 字符串 - unilabos_class → ClassSlot → "class_name" 字符串
- unilabos_formulation → FormulationSlot → [{well_name, liquids: [{name, volume}]}] 配方数组
- 特例create_resource 的 res_id 允许填不存在的路径 - 特例create_resource 的 res_id 允许填不存在的路径
- 列出本设备所有 Slot 字段、类型及含义 - 列出本设备所有 Slot 字段、类型及含义
## 渐进加载策略 ## 渐进加载策略
## 完整工作流 Checklist ## 完整工作流 Checklist
``` ```
### Step 5 — 验证 ### Step 5 — 验证
检查文件完整性: 检查文件完整性:
- [ ] `SKILL.md` 包含 10 个 API endpoint
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot + create_resource 特例)和本设备的 Slot 字段表 - [ ] `SKILL.md` 包含 API endpoint#1 获取 lab_uuid、#2-#7 工作流/节点/边、#8-#11 运行/查询、#12 资源树、#13 工作流模板详情、#14-#16 物料管理)
- [ ] `SKILL.md` 包含 Placeholder Slot 填写规则ResourceSlot / DeviceSlot / NodeSlot / ClassSlot / FormulationSlot + create_resource 特例)和本设备的 Slot 字段表
- [ ] `action-index.md` 列出所有 action 并有描述 - [ ] `action-index.md` 列出所有 action 并有描述
- [ ] `actions/` 目录中每个 action 有对应 JSON 文件 - [ ] `actions/` 目录中每个 action 有对应 JSON 文件
- [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段 - [ ] JSON 文件包含 `type`, `schema`(已提升为 goal 内容), `goal`, `goal_default`, `placeholder_keys` 字段
@@ -249,71 +290,202 @@ API 模板结构:
``` ```
> **注意**`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。 > **注意**`schema` 已由脚本从原始 `schema.properties.goal` 提升为顶层,直接包含参数定义。
> `schema.properties` 中的字段即为 API 请求 `param.goal` 中的字段 > `schema.properties` 中的字段即为 API 创建节点返回的 `data.param` 中的字段PATCH 更新时直接修改 `param` 即可
## Placeholder Slot 类型体系 ## Placeholder Slot 类型体系
`placeholder_keys` / `_unilabos_placeholder_info` 中有 4 种值,对应不同的填写方式: `placeholder_keys` / `_unilabos_placeholder_info` 中有 5 种值,对应不同的填写方式:
| placeholder 值 | Slot 类型 | 填写格式 | 选取范围 | | placeholder 值 | Slot 类型 | 填写格式 | 选取范围 |
|---------------|-----------|---------|---------| | ---------------------- | --------------- | ----------------------------------------------------- | ----------------------------------------- |
| `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) | | `unilabos_resources` | ResourceSlot | `{"id": "/path/name", "name": "name", "uuid": "xxx"}` | 仅**物料**节点(不含设备) |
| `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点type=device路径字符串 | | `unilabos_devices` | DeviceSlot | `"/parent/device_name"` | 仅**设备**节点type=device路径字符串 |
| `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 | | `unilabos_nodes` | NodeSlot | `"/parent/node_name"` | **设备 + 物料**,即所有节点,路径字符串 |
| `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name | | `unilabos_class` | ClassSlot | `"class_name"` | 注册表中已上报的资源类 name |
| `unilabos_formulation` | FormulationSlot | `[{well_name, liquids: [{name, volume}]}]` | 资源树中物料节点的 **name**,配合液体配方 |
### ResourceSlot`unilabos_resources` ### ResourceSlot`unilabos_resources`
最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等): 最常见的类型。从资源树中选取**物料**节点(孔板、枪头盒、试剂槽等):
- 单个:`{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-..."}`
- 数组:`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]`
- `id` 从 parent 计算的路径格式,根据 action 语义选择正确的物料
> **特例**`create_resource` 的 `res_id`,目标物料可能尚不存在,直接填期望路径,不需要 uuid。
### DeviceSlot / NodeSlot / ClassSlot
- **DeviceSlot**`unilabos_devices`):路径字符串如 `"/host_node"`,仅 type=device 的节点
- **NodeSlot**`unilabos_nodes`):路径字符串如 `"/PRCXI/PRCXI_Deck"`,设备 + 物料均可选
- **ClassSlot**`unilabos_class`):类名字符串如 `"container"`,从 `req_resource_registry_upload.json` 查找
### FormulationSlot`unilabos_formulation`
描述**液体配方**:向哪些容器中加入哪些液体及体积。
```json ```json
{"id": "/workstation/container1", "name": "container1", "uuid": "ff149a9a-2cb8-419d-8db5-d3ba056fb3c2"} [
{
"sample_uuid": "",
"well_name": "bottle_A1",
"liquids": [{ "name": "LiPF6", "volume": 0.6 }]
}
]
``` ```
- 单个schema type=object`{"id": "/path/name", "name": "name", "uuid": "xxx"}` - `well_name` — 目标物料的 **name**(从资源树取,不是 `id` 路径)
- 数组schema type=array`[{"id": "/path/a", "name": "a", "uuid": "xxx"}, ...]` - `liquids[]` — 液体列表,每条含 `name`(试剂名)和 `volume`体积单位由上下文决定pylabrobot 内部统一 uL
- `id` 本身是从 parent 计算的路径格式 - `sample_uuid` — 样品 UUID无样品传 `""`
- 根据 action 语义选择正确的物料(如 `sources` = 液体来源,`targets` = 目标位置) - 与 ResourceSlot 的区别ResourceSlot 指向物料本身FormulationSlot 引用物料名并附带配方信息
> **特例**`create_resource` 的 `res_id` 字段,目标物料可能**尚不存在**,此时直接填写期望的路径(如 `"/workstation/container1"`),不需要 uuid。 ### 通过 API #12 获取资源树
### 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 ```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH" curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
``` ```
注意 `lab_uuid` 在路径中(不是查询参数)。资源树返回所有节点,每个节点包含 `id`(路径格式)、`name``uuid``type``parent` 等字段。填写 Slot 时需根据 placeholder 类型筛选正确的节点。 注意 `lab_uuid` 在路径中(不是查询参数)。返回结构:
```json
{
"code": 0,
"data": {
"nodes": [
{"name": "host_node", "uuid": "c3ec1e68-...", "type": "device", "parent": ""},
{"name": "PRCXI", "uuid": "e249c9a6-...", "type": "device", "parent": ""},
{"name": "PRCXI_Deck", "uuid": "fb6a8b71-...", "type": "deck", "parent": "PRCXI"}
],
"edges": [...]
}
}
```
- `data.nodes[]` — 所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
- `type` 区分设备(`device`)和物料(`deck``container``resource` 等)
- `parent` 为父节点名称(空字符串表示顶级)
- 填写 Slot 时根据 placeholder 类型筛选ResourceSlot 取非 device 节点DeviceSlot 取 device 节点
- 创建/更新物料时:`parent_uuid` 取父节点的 `uuid`,更新目标的 `uuid` 取节点自身的 `uuid`
## 物料管理 API
设备 Skill 除了设备动作外,还需支持物料节点的创建和参数设定,用于在资源树中动态管理物料。
典型流程:先通过 **#14 按名称查询模板** 获取 `res_template_uuid` → 再通过 **#15 创建物料** → 之后可通过 **#16 更新物料** 修改属性。更新时需要的 `uuid``parent_uuid` 均从 **#12 资源树下载** 获取。
### API #14 — 按名称查询物料模板
创建物料前,需要先获取物料模板的 UUID。通过模板名称查询
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
| 参数 | 必填 | 说明 |
| ---------- | ------ | -------------------------------- |
| `lab_uuid` | **是** | 实验室 UUID从 API #1 获取) |
| `name` | **是** | 物料模板名称(如 `"container"` |
返回 `code: 0` 时,**`data.uuid`** 即为 `res_template_uuid`,用于 API #15 创建物料。返回还包含 `name``resource_type``handles``config_infos` 等模板元信息。
模板不存在时返回 `code: 10002``data` 为空对象。模板名称来自资源注册表中已注册的资源类型。
### API #15 — 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"res_template_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "my_custom_bottle",
"display_name": "自定义瓶子",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"type": "",
"init_param_data": {},
"schema": {},
"data": {
"liquids": [["water", 1000, "uL"]],
"max_volume": 50000
},
"plate_well_datas": {},
"plate_reagent_datas": {},
"pose": {},
"model": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| --------------------- | ------ | ------------- | ----------------------------------- | -------------------------------------- |
| `res_template_uuid` | **是** | string (UUID) | **API #14** 按名称查询获取 | 物料模板 UUID |
| `name` | 否 | string | **用户自定义** | 节点名称(标识符),可自由命名 |
| `display_name` | 否 | string | 用户自定义 | 显示名称UI 展示用) |
| `parent_uuid` | 否 | string (UUID) | **API #12** 资源树中父节点的 `uuid` | 父节点,为空则创建顶级节点 |
| `type` | 否 | string | 从模板继承 | 节点类型 |
| `init_param_data` | 否 | object | 用户指定 | 初始化参数,覆盖模板默认值 |
| `data` | 否 | object | 用户指定 | 节点数据container 见下方 data 格式 |
| `plate_well_datas` | 否 | object | 用户指定 | 孔板子节点数据(创建带孔位的板时使用) |
| `plate_reagent_datas` | 否 | object | 用户指定 | 试剂关联数据 |
| `schema` | 否 | object | 从模板继承 | 自定义 schema不传则从模板继承 |
| `pose` | 否 | object | 用户指定 | 位姿信息 |
| `model` | 否 | object | 用户指定 | 3D 模型信息 |
#### container 的 `data` 格式
> **体积单位统一为 uL微升**。pylabrobot 体系中所有体积值(`max_volume`、`liquids` 中的 volume均为 uL。外部如果是 mL 需乘 1000 转换。
```json
{
"liquids": [["water", 1000, "uL"], ["ethanol", 500, "uL"]],
"max_volume": 50000
}
```
- `liquids` — 液体列表,每条为 `[液体名称, 体积(uL), 单位字符串]`
- `max_volume` — 容器最大容量uL如 50 mL = 50000 uL
### API #16 — 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体:
```json
{
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"parent_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"display_name": "新显示名称",
"description": "新描述",
"init_param_data": {},
"data": {},
"pose": {},
"schema": {},
"extra": {}
}
```
| 字段 | 必填 | 类型 | 数据来源 | 说明 |
| ----------------- | ------ | ------------- | ------------------------------------- | ---------------- |
| `uuid` | **是** | string (UUID) | **API #12** 资源树中目标节点的 `uuid` | 要更新的物料节点 |
| `parent_uuid` | 否 | string (UUID) | API #12 资源树 | 移动到新父节点 |
| `display_name` | 否 | string | 用户指定 | 更新显示名称 |
| `description` | 否 | string | 用户指定 | 更新描述 |
| `init_param_data` | 否 | object | 用户指定 | 更新初始化参数 |
| `data` | 否 | object | 用户指定 | 更新节点数据 |
| `pose` | 否 | object | 用户指定 | 更新位姿 |
| `schema` | 否 | object | 用户指定 | 更新 schema |
| `extra` | 否 | object | 用户指定 | 更新扩展数据 |
> 只传需要更新的字段,未传的字段保持不变。
## 最终目录结构 ## 最终目录结构

View File

@@ -0,0 +1,251 @@
---
name: host-node
description: Operate Uni-Lab host node via REST API — create resources, test latency, test resource tree, manual confirm. Use when the user mentions host_node, creating resources, resource management, testing latency, or any host node operation.
---
# Host Node API Skill
## 设备信息
- **device_id**: `host_node`
- **Python 源码**: `unilabos/ros/nodes/presets/host_node.py`
- **设备类**: `HostNode`
- **动作数**: 4`create_resource`, `test_latency`, `auto-test_resource`, `manual_confirm`
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`base64(ak:sk)``Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
在整个对话过程中agent 需要记住以下状态,避免重复询问用户:
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``host_node`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"host_node","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `host_node`
- `node_template_name` — action 名称(如 `create_resource`, `test_latency`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
`param` 直接使用创建节点返回的 `data.param` 结构,修改需要填入的字段值。参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"host_node","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `test_latency` | `UniLabJsonCommand` |
| `create_resource` | `ResourceCreateFromOuterEasy` |
| `auto-test_resource` | `UniLabJsonCommand` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | 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"` | 注册表中已注册的资源类 |
### host_node 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ----------- | ------------ | ------------------------------ |
| `create_resource` | `res_id` | ResourceSlot | 新资源路径(可填不存在的路径) |
| `create_resource` | `device_id` | DeviceSlot | 归属设备 |
| `create_resource` | `parent` | NodeSlot | 父节点路径 |
| `create_resource` | `class_name`| ClassSlot | 资源类名如 `"container"` |
| `auto-test_resource` | `resource` | ResourceSlot | 单个测试物料 |
| `auto-test_resource` | `resources` | ResourceSlot | 测试物料数组 |
| `auto-test_resource` | `device` | DeviceSlot | 测试设备 |
| `auto-test_resource` | `devices` | DeviceSlot | 测试设备 |
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理
2. **[action-index.md](action-index.md)** — 按分类浏览 4 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=host_node) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```

View File

@@ -0,0 +1,58 @@
# Action Index — host_node
4 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 资源管理
### `create_resource`
在资源树中创建新资源(容器、物料等),支持指定位置、类型和初始液体
- **action_type**: `ResourceCreateFromOuterEasy`
- **Schema**: [`actions/create_resource.json`](actions/create_resource.json)
- **可选参数**: `res_id`, `device_id`, `class_name`, `parent`, `bind_locations`, `liquid_input_slot`, `liquid_type`, `liquid_volume`, `slot_on_deck`
- **占位符字段**:
- `res_id`**ResourceSlot**(特例:目标物料可能尚不存在,直接填期望路径)
- `device_id`**DeviceSlot**,填路径字符串如 `"/host_node"`
- `parent`**NodeSlot**,填路径字符串如 `"/workstation/deck"`
- `class_name`**ClassSlot**,填类名如 `"container"`
### `auto-test_resource`
测试资源系统,返回当前资源树和设备列表
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_resource.json`](actions/test_resource.json)
- **可选参数**: `resource`, `resources`, `device`, `devices`
- **占位符字段**:
- `resource`**ResourceSlot**,单个物料节点 `{id, name, uuid}`
- `resources`**ResourceSlot**,物料节点数组 `[{id, name, uuid}, ...]`
- `device`**DeviceSlot**,设备路径字符串
- `devices`**DeviceSlot**,设备路径字符串
---
## 系统工具
### `test_latency`
测试设备通信延迟,返回 RTT、时间差、任务延迟等指标
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/test_latency.json`](actions/test_latency.json)
- **参数**: 无(零参数调用)
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `timeout_seconds`(超时时间,秒), `assignee_user_ids`(指派用户 ID 列表)
- **占位符字段**: `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -0,0 +1,93 @@
{
"type": "ResourceCreateFromOuterEasy",
"goal": {
"res_id": "res_id",
"class_name": "class_name",
"parent": "parent",
"device_id": "device_id",
"bind_locations": "bind_locations",
"liquid_input_slot": "liquid_input_slot[]",
"liquid_type": "liquid_type[]",
"liquid_volume": "liquid_volume[]",
"slot_on_deck": "slot_on_deck"
},
"schema": {
"type": "object",
"properties": {
"res_id": {
"type": "string"
},
"device_id": {
"type": "string"
},
"class_name": {
"type": "string"
},
"parent": {
"type": "string"
},
"bind_locations": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "bind_locations",
"additionalProperties": false
},
"liquid_input_slot": {
"type": "array",
"items": {
"type": "integer"
}
},
"liquid_type": {
"type": "array",
"items": {
"type": "string"
}
},
"liquid_volume": {
"type": "array",
"items": {
"type": "number"
}
},
"slot_on_deck": {
"type": "string"
}
},
"required": [],
"_unilabos_placeholder_info": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
},
"goal_default": {},
"placeholder_keys": {
"res_id": "unilabos_resources",
"device_id": "unilabos_devices",
"parent": "unilabos_nodes",
"class_name": "unilabos_class"
}
}

View File

@@ -0,0 +1,32 @@
{
"type": "UniLabJsonCommand",
"goal": {
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -0,0 +1,11 @@
{
"type": "UniLabJsonCommand",
"goal": {},
"schema": {
"type": "object",
"properties": {},
"required": []
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,255 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"resources": "resources",
"device": "device",
"devices": "devices"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"resources": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resources"
},
"type": "array"
},
"device": {
"type": "string",
"description": "device reference"
},
"devices": {
"type": "string",
"description": "device reference"
}
},
"required": [],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"resources": "unilabos_resources",
"device": "unilabos_devices",
"devices": "unilabos_devices"
}
}

View File

@@ -0,0 +1,284 @@
---
name: submit-agent-result
description: Submit historical experiment results (agent_result) to Uni-Lab cloud platform (leap-lab) notebook — read data files, assemble JSON payload, PUT to cloud API. Use when the user wants to submit experiment results, upload agent results, report experiment data, or mentions agent_result/实验结果/历史记录/notebook结果.
---
# Uni-Lab 提交历史实验记录指南
通过 Uni-Lab 云端 API 向已创建的 notebook 提交实验结果数据agent_result。支持从 JSON / CSV 文件读取数据,整合后提交。
> **重要**:本指南中的 `Authorization: Lab <token>` 是 **Uni-Lab 平台专用的认证方式**`Lab` 是 Uni-Lab 的 auth scheme 关键字,**不是** HTTP Basic 认证。请勿将其替换为 `Basic`。
## 前置条件(缺一不可)
使用本指南前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
询问用户的启动参数,从 `--ak` `--sk` 或 config.py 中获取。
生成 AUTH token
```bash
# ⚠️ 注意scheme 是 "Lab"Uni-Lab 专用),不是 "Basic"
python -c "import base64,sys; print(base64.b64encode(f'{sys.argv[1]}:{sys.argv[2]}'.encode()).decode())" <ak> <sk>
```
输出即为 token 值,拼接为 `Authorization: Lab <token>``Lab` 是 Uni-Lab 平台 auth scheme不可替换为 `Basic`)。
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
# ⚠️ Auth scheme 必须是 "Lab"Uni-Lab 专用),不是 "Basic"
AUTH="Authorization: Lab <上面命令输出的 token>"
```
### 3. notebook_uuid**必须询问用户**
**必须主动询问用户**:「请提供要提交结果的 notebook UUID。」
notebook_uuid 来自之前通过「批量提交实验」创建的实验批次,即 `POST /api/v1/lab/notebook` 返回的 `data.uuid`
如果用户不记得,可提示:
- 查看之前的对话记录中创建 notebook 时返回的 UUID
- 或通过平台页面查找对应的 notebook
**绝不能跳过此步骤,没有 notebook_uuid 无法提交。**
### 4. 实验结果数据
用户需要提供实验结果数据,支持以下方式:
| 方式 | 说明 |
| --------- | ----------------------------------------------- |
| JSON 文件 | 直接作为 `agent_result` 的内容合并 |
| CSV 文件 | 转为 `{"文件名": [行数据...]}` 格式 |
| 手动指定 | 用户直接告知 key-value 数据,由 agent 构建 JSON |
**四项全部就绪后才可开始。**
## Session State
在整个对话过程中agent 需要记住以下状态:
- `lab_uuid` — 实验室 UUID通过 API #1 自动获取,**不需要问用户**
- `notebook_uuid` — 目标 notebook UUID**必须询问用户**
## 请求约定
所有请求使用 `curl -s`PUT 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名),示例中的 `curl` 均指 `curl.exe`。
>
> **PowerShell JSON 传参**PowerShell 中 `-d '{"key":"value"}'` 会因引号转义失败。请将 JSON 写入临时文件,用 `-d '@tmp_body.json'`(单引号包裹 `@`,否则 `@` 会被 PowerShell 解析为 splatting 运算符导致报错)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回:
```json
{ "code": 0, "data": { "uuid": "xxx", "name": "实验室名称" } }
```
记住 `data.uuid``lab_uuid`
### 2. 提交实验结果agent_result
```bash
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '<request_body>'
```
请求体结构:
```json
{
"notebook_uuid": "<notebook_uuid>",
"agent_result": {
"<key1>": "<value1>",
"<key2>": 123,
"<nested_key>": {"a": 1, "b": 2},
"<array_key>": [{"col1": "v1", "col2": "v2"}, ...]
}
}
```
> **注意**HTTP 方法是 **PUT**(不是 POST
#### 必要字段
| 字段 | 类型 | 说明 |
| --------------- | ------------- | ------------------------------------------- |
| `notebook_uuid` | string (UUID) | 目标 notebook 的 UUID从批量提交实验时获取 |
| `agent_result` | object | 实验结果数据,任意 JSON 对象 |
#### agent_result 内容格式
`agent_result` 接受**任意 JSON 对象**,常见格式:
**简单键值对**
```json
{
"avg_rtt_ms": 12.5,
"status": "success",
"test_count": 5
}
```
**包含嵌套结构**
```json
{
"summary": { "total": 100, "passed": 98, "failed": 2 },
"measurements": [
{ "sample_id": "S001", "value": 3.14, "unit": "mg/mL" },
{ "sample_id": "S002", "value": 2.71, "unit": "mg/mL" }
]
}
```
**从 CSV 文件导入**(脚本自动转换):
```json
{
"experiment_data": [
{ "温度": 25, "压力": 101.3, "产率": 0.85 },
{ "温度": 30, "压力": 101.3, "产率": 0.91 }
]
}
```
---
## 整合脚本
本文档同级目录下的 `scripts/prepare_agent_result.py` 可自动读取文件并构建请求体。
### 用法
```bash
python scripts/prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
```
| 参数 | 必选 | 说明 |
| ----------------- | ---------- | ----------------------------------------------- |
| `--notebook-uuid` | 是 | 目标 notebook UUID |
| `--files` | 是 | 输入文件路径支持多个JSON / CSV |
| `--auth` | 提交时必选 | Lab tokenbase64(ak:sk) |
| `--base` | 提交时必选 | API base URL |
| `--submit` | 否 | 加上此标志则直接提交到云端 |
| `--output` | 否 | 输出 JSON 路径(默认 `agent_result_body.json` |
### 文件合并规则
| 文件类型 | 合并方式 |
| --------------------- | -------------------------------------------- |
| `.json`dict | 字段直接合并到 `agent_result` 顶层 |
| `.json`list/other | 以文件名为 key 放入 `agent_result` |
| `.csv` | 以文件名(不含扩展名)为 key值为行对象数组 |
多个文件的字段会合并。JSON dict 中的重复 key 后者覆盖前者。
### 示例
```bash
# 仅生成请求体文件(不提交)
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json measurements.csv
# 生成并直接提交
python scripts/prepare_agent_result.py \
--notebook-uuid 73c67dca-c8cc-4936-85a0-329106aa7cca \
--files results.json \
--auth YTFmZDlkNGUt... \
--base https://leap-lab.test.bohrium.com \
--submit
```
---
## 手动构建方式
如果不使用脚本,也可手动构建请求体:
1. 将实验结果数据组装为 JSON 对象
2. 写入临时文件:
```json
{
"notebook_uuid": "<uuid>",
"agent_result": { ... }
}
```
3. 用 curl 提交:
```bash
curl -s -X PUT "$BASE/api/v1/lab/notebook/agent-result" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '@tmp_body.json'
```
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: 确认 ak/sk → 生成 AUTH token
- [ ] Step 2: 确认 --addr → 设置 BASE URL
- [ ] Step 3: GET /edge/lab/info → 获取 lab_uuid
- [ ] Step 4: **询问用户** notebook_uuid必须不可跳过
- [ ] Step 5: 确认实验结果数据来源(文件路径或手动数据)
- [ ] Step 6: 运行 prepare_agent_result.py 或手动构建请求体
- [ ] Step 7: PUT /lab/notebook/agent-result 提交
- [ ] Step 8: 检查返回结果,确认提交成功
```
---
## 常见问题
### Q: notebook_uuid 从哪里获取?
从之前「批量提交实验」时 `POST /api/v1/lab/notebook` 的返回值 `data.uuid` 获取。也可以在平台 UI 中查找对应的 notebook。
### Q: agent_result 有固定的 schema 吗?
没有严格 schema接受任意 JSON 对象。但建议包含有意义的字段名和结构化数据,方便后续分析。
### Q: 可以多次提交同一个 notebook 的结果吗?
可以,后续提交会覆盖之前的 agent_result。
### Q: 认证方式是 Lab 还是 Api
本指南统一使用 `Authorization: Lab <base64(ak:sk)>` 方式(`Lab` 是 Uni-Lab 平台的 auth scheme**绝不能用 `Basic` 替代**)。如果用户有独立的 API Key也可用 `Authorization: Api <key>` 替代。

View File

@@ -0,0 +1,133 @@
"""
读取实验结果文件JSON / CSV整合为 agent_result 请求体并可选提交。
用法:
python prepare_agent_result.py \
--notebook-uuid <uuid> \
--files data1.json data2.csv \
[--auth <Lab token>] \
[--base <BASE_URL>] \
[--submit] \
[--output <output.json>]
支持的输入文件格式:
- .json → 直接作为 dict 合并
- .csv → 转为 {"filename": [row_dict, ...]} 格式
"""
import argparse
import base64
import csv
import json
import os
import sys
from pathlib import Path
from typing import Any, Dict, List
def read_json_file(filepath: str) -> Dict[str, Any]:
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
def read_csv_file(filepath: str) -> List[Dict[str, Any]]:
rows = []
with open(filepath, "r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
converted = {}
for k, v in row.items():
try:
converted[k] = int(v)
except (ValueError, TypeError):
try:
converted[k] = float(v)
except (ValueError, TypeError):
converted[k] = v
rows.append(converted)
return rows
def merge_files(filepaths: List[str]) -> Dict[str, Any]:
"""将多个文件合并为一个 agent_result dict"""
merged: Dict[str, Any] = {}
for fp in filepaths:
path = Path(fp)
ext = path.suffix.lower()
key = path.stem
if ext == ".json":
data = read_json_file(fp)
if isinstance(data, dict):
merged.update(data)
else:
merged[key] = data
elif ext == ".csv":
merged[key] = read_csv_file(fp)
else:
print(f"[警告] 不支持的文件格式: {fp},跳过", file=sys.stderr)
return merged
def build_request_body(notebook_uuid: str, agent_result: Dict[str, Any]) -> Dict[str, Any]:
return {
"notebook_uuid": notebook_uuid,
"agent_result": agent_result,
}
def submit(base: str, auth: str, body: Dict[str, Any]) -> Dict[str, Any]:
try:
import requests
except ImportError:
print("[错误] 提交需要 requests 库: pip install requests", file=sys.stderr)
sys.exit(1)
url = f"{base}/api/v1/lab/notebook/agent-result"
headers = {
"Content-Type": "application/json",
"Authorization": f"Lab {auth}",
}
resp = requests.put(url, json=body, headers=headers, timeout=30)
return {"status_code": resp.status_code, "body": resp.json() if resp.headers.get("content-type", "").startswith("application/json") else resp.text}
def main():
parser = argparse.ArgumentParser(description="整合实验结果文件并构建 agent_result 请求体")
parser.add_argument("--notebook-uuid", required=True, help="目标 notebook UUID")
parser.add_argument("--files", nargs="+", required=True, help="输入文件路径JSON / CSV")
parser.add_argument("--auth", help="Lab tokenbase64(ak:sk)")
parser.add_argument("--base", help="API base URL")
parser.add_argument("--submit", action="store_true", help="直接提交到云端")
parser.add_argument("--output", default="agent_result_body.json", help="输出 JSON 文件路径")
args = parser.parse_args()
for fp in args.files:
if not os.path.exists(fp):
print(f"[错误] 文件不存在: {fp}", file=sys.stderr)
sys.exit(1)
agent_result = merge_files(args.files)
body = build_request_body(args.notebook_uuid, agent_result)
with open(args.output, "w", encoding="utf-8") as f:
json.dump(body, f, ensure_ascii=False, indent=2)
print(f"[完成] 请求体已保存: {args.output}")
print(f" notebook_uuid: {args.notebook_uuid}")
print(f" agent_result 字段数: {len(agent_result)}")
print(f" 合并文件数: {len(args.files)}")
if args.submit:
if not args.auth or not args.base:
print("[错误] 提交需要 --auth 和 --base 参数", file=sys.stderr)
sys.exit(1)
print(f"\n[提交] PUT {args.base}/api/v1/lab/notebook/agent-result ...")
result = submit(args.base, args.auth, body)
print(f" HTTP {result['status_code']}")
print(f" 响应: {json.dumps(result['body'], ensure_ascii=False)}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,272 @@
---
name: virtual-workbench
description: Operate Virtual Workbench via REST API — prepare materials, move to heating stations, start heating, move to output, transfer resources. Use when the user mentions virtual workbench, virtual_workbench, 虚拟工作台, heating stations, material processing, or workbench operations.
---
# Virtual Workbench API Skill
## 设备信息
- **device_id**: `virtual_workbench`
- **Python 源码**: `unilabos/devices/virtual/workbench.py`
- **设备类**: `VirtualWorkbench`
- **动作数**: 6`auto-prepare_materials`, `auto-move_to_heating_station`, `auto-start_heating`, `auto-move_to_output`, `transfer`, `manual_confirm`
- **设备描述**: 模拟工作台,包含 1 个机械臂(每次操作 2s独占锁和 3 个加热台(每次加热 60s可并行
### 典型工作流程
1. `prepare_materials` — 生成 A1-A5 物料5 个 output handle
2. `move_to_heating_station` — 物料并发竞争机械臂,移动到空闲加热台
3. `start_heating` — 启动加热3 个加热台可并行)
4. `move_to_output` — 加热完成后移到输出位置 Cn
## 前置条件(缺一不可)
使用本 skill 前,**必须**先确认以下信息。如果缺少任何一项,**立即向用户询问并终止**,等补齐后再继续。
### 1. ak / sk → AUTH
从启动参数 `--ak` `--sk` 或 config.py 中获取,生成 token`base64(ak:sk)``Authorization: Lab <token>`
### 2. --addr → BASE URL
| `--addr` 值 | BASE |
| ------------ | ----------------------------------- |
| `test` | `https://leap-lab.test.bohrium.com` |
| `uat` | `https://leap-lab.uat.bohrium.com` |
| `local` | `http://127.0.0.1:48197` |
| 不传(默认) | `https://leap-lab.bohrium.com` |
确认后设置:
```bash
BASE="<根据 addr 确定的 URL>"
AUTH="Authorization: Lab <token>"
```
**两项全部就绪后才可发起 API 请求。**
## Session State
- `lab_uuid` — 实验室 UUID首次通过 API #1 自动获取,**不需要问用户**
- `device_name``virtual_workbench`
## 请求约定
所有请求使用 `curl -s`POST/PATCH/DELETE 需加 `Content-Type: application/json`
> **Windows 平台**必须使用 `curl.exe`(而非 PowerShell 的 `curl` 别名)。
---
## API Endpoints
### 1. 获取实验室信息(自动获取 lab_uuid
```bash
curl -s -X GET "$BASE/api/v1/edge/lab/info" -H "$AUTH"
```
返回 `data.uuid``lab_uuid``data.name``lab_name`
### 2. 创建工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/owner" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"name":"<名称>","lab_uuid":"<lab_uuid>","description":"<描述>"}'
```
返回 `data.uuid``workflow_uuid`。创建成功后告知用户链接:`$BASE/laboratory/$lab_uuid/workflow/$workflow_uuid`
### 3. 创建节点
```bash
curl -s -X POST "$BASE/api/v1/edge/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<workflow_uuid>","resource_template_name":"virtual_workbench","node_template_name":"<action_name>"}'
```
- `resource_template_name` 固定为 `virtual_workbench`
- `node_template_name` — action 名称(如 `auto-prepare_materials`, `auto-move_to_heating_station`
### 4. 删除节点
```bash
curl -s -X DELETE "$BASE/api/v1/lab/workflow/nodes" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<uuid1>"],"workflow_uuid":"<workflow_uuid>"}'
```
### 5. 更新节点参数
```bash
curl -s -X PATCH "$BASE/api/v1/lab/workflow/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"workflow_uuid":"<wf_uuid>","uuid":"<node_uuid>","param":{...}}'
```
参考 [action-index.md](action-index.md) 确定哪些字段是 Slot。
### 6. 查询节点 handles
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/node-handles" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuids":["<node_uuid_1>","<node_uuid_2>"]}'
```
### 7. 批量创建边
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/edges" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"edges":[{"source_node_uuid":"<uuid>","target_node_uuid":"<uuid>","source_handle_uuid":"<uuid>","target_handle_uuid":"<uuid>"}]}'
```
### 8. 启动工作流
```bash
curl -s -X POST "$BASE/api/v1/lab/workflow/<workflow_uuid>/run" -H "$AUTH"
```
### 9. 运行设备单动作
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"lab_uuid":"<lab_uuid>","device_id":"virtual_workbench","action":"<action_name>","action_type":"<type>","param":{...}}'
```
`param` 直接放 goal 里的属性,**不要**再包一层 `{"goal": {...}}`
> **WARNING: `action_type` 必须正确,传错会导致任务永远卡住无法完成。** 从下表或 `actions/<name>.json` 的 `type` 字段获取。
#### action_type 速查表
| action | action_type |
|--------|-------------|
| `auto-prepare_materials` | `UniLabJsonCommand` |
| `auto-move_to_heating_station` | `UniLabJsonCommand` |
| `auto-start_heating` | `UniLabJsonCommand` |
| `auto-move_to_output` | `UniLabJsonCommand` |
| `transfer` | `UniLabJsonCommandAsync` |
| `manual_confirm` | `UniLabJsonCommand` |
### 10. 查询任务状态
```bash
curl -s -X GET "$BASE/api/v1/lab/mcp/task/<task_uuid>" -H "$AUTH"
```
### 11. 运行工作流单节点
```bash
curl -s -X POST "$BASE/api/v1/lab/mcp/run/workflow/action" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"node_uuid":"<node_uuid>"}'
```
### 12. 获取资源树(物料信息)
```bash
curl -s -X GET "$BASE/api/v1/lab/material/download/$lab_uuid" -H "$AUTH"
```
注意 `lab_uuid` 在路径中。返回 `data.nodes[]` 含所有节点(设备 + 物料),每个节点含 `name``uuid``type``parent`
### 13. 获取工作流模板详情
```bash
curl -s -X GET "$BASE/api/v1/lab/workflow/template/detail/$workflow_uuid" -H "$AUTH"
```
> 必须使用 `/lab/workflow/template/detail/{uuid}`,其他路径会返回 404。
### 14. 按名称查询物料模板
```bash
curl -s -X GET "$BASE/api/v1/lab/material/template/by-name?lab_uuid=$lab_uuid&name=<template_name>" -H "$AUTH"
```
返回 `data.uuid``res_template_uuid`,用于 API #15
### 15. 创建物料节点
```bash
curl -s -X POST "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"res_template_uuid":"<uuid>","name":"<名称>","display_name":"<显示名>","parent_uuid":"<父节点uuid>","data":{...}}'
```
### 16. 更新物料节点
```bash
curl -s -X PUT "$BASE/api/v1/edge/material/node" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{"uuid":"<节点uuid>","display_name":"<新名称>","data":{...}}'
```
---
## Placeholder Slot 填写规则
| `placeholder_keys` 值 | 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"` | 注册表中已注册的资源类 |
### virtual_workbench 设备的 Slot 字段表
| Action | 字段 | Slot 类型 | 说明 |
| ----------------- | ---------------- | ------------ | -------------------- |
| `transfer` | `resource` | ResourceSlot | 待转移物料数组 |
| `transfer` | `target_device` | DeviceSlot | 目标设备路径 |
| `transfer` | `mount_resource` | ResourceSlot | 目标孔位数组 |
| `manual_confirm` | `resource` | ResourceSlot | 确认用物料数组 |
| `manual_confirm` | `target_device` | DeviceSlot | 确认用目标设备 |
| `manual_confirm` | `mount_resource` | ResourceSlot | 确认用目标孔位数组 |
> `prepare_materials`、`move_to_heating_station`、`start_heating`、`move_to_output` 这 4 个动作**无 Slot 字段**,参数为纯数值/整数。
---
## 渐进加载策略
1. **SKILL.md**(本文件)— API 端点 + session state 管理 + 设备工作流概览
2. **[action-index.md](action-index.md)** — 按分类浏览 6 个动作的描述和核心参数
3. **[actions/\<name\>.json](actions/)** — 仅在需要构建具体请求时,加载对应 action 的完整 JSON Schema
---
## 完整工作流 Checklist
```
Task Progress:
- [ ] Step 1: GET /edge/lab/info 获取 lab_uuid
- [ ] Step 2: 获取资源树 (GET #12) → 记住可用物料
- [ ] Step 3: 读 action-index.md 确定要用的 action 名
- [ ] Step 4: 创建工作流 (POST #2) → 记住 workflow_uuid告知用户链接
- [ ] Step 5: 创建节点 (POST #3, resource_template_name=virtual_workbench) → 记住 node_uuid + data.param
- [ ] Step 6: 根据 _unilabos_placeholder_info 和资源树,填写 data.param 中的 Slot 字段
- [ ] Step 7: 更新节点参数 (PATCH #5)
- [ ] Step 8: 查询节点 handles (POST #6) → 获取各节点的 handle_uuid
- [ ] Step 9: 批量创建边 (POST #7) → 用 handle_uuid 连接节点
- [ ] Step 10: 启动工作流 (POST #8) 或运行单节点 (POST #11)
- [ ] Step 11: 查询任务状态 (GET #10) 确认完成
```
### 典型 5 物料并发加热工作流示例
```
prepare_materials (count=5)
├─ channel_1 → move_to_heating_station (material_number=1) → start_heating → move_to_output
├─ channel_2 → move_to_heating_station (material_number=2) → start_heating → move_to_output
├─ channel_3 → move_to_heating_station (material_number=3) → start_heating → move_to_output
├─ channel_4 → move_to_heating_station (material_number=4) → start_heating → move_to_output
└─ channel_5 → move_to_heating_station (material_number=5) → start_heating → move_to_output
```
创建节点时,`prepare_materials` 的 5 个 output handle`channel_1` ~ `channel_5`)分别连接到 5 个 `move_to_heating_station` 节点的 `material_input` handle。每个 `move_to_heating_station``heating_station_output``material_number_output` 连接到对应 `start_heating``station_id_input``material_number_input`

View File

@@ -0,0 +1,76 @@
# Action Index — virtual_workbench
6 个动作,按功能分类。每个动作的完整 JSON Schema 在 `actions/<name>.json`
---
## 物料准备
### `auto-prepare_materials`
批量准备物料(虚拟起始节点),生成 A1-A5 物料编号,输出 5 个 handle 供后续节点使用
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/prepare_materials.json`](actions/prepare_materials.json)
- **可选参数**: `count`(物料数量,默认 5
---
## 机械臂 & 加热台操作
### `auto-move_to_heating_station`
将物料从 An 位置移动到空闲加热台(竞争机械臂,自动查找空闲加热台)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_heating_station.json`](actions/move_to_heating_station.json)
- **核心参数**: `material_number`物料编号integer
### `auto-start_heating`
启动指定加热台的加热程序可并行3 个加热台同时工作)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/start_heating.json`](actions/start_heating.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
### `auto-move_to_output`
将加热完成的物料从加热台移动到输出位置 Cn
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/move_to_output.json`](actions/move_to_output.json)
- **核心参数**: `station_id`(加热台 ID`material_number`(物料编号)
---
## 物料转移
### `transfer`
异步转移物料到目标设备(通过 ROS 资源转移)
- **action_type**: `UniLabJsonCommandAsync`
- **Schema**: [`actions/transfer.json`](actions/transfer.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`
- **占位符字段**:
- `resource`**ResourceSlot**,待转移的物料数组 `[{id, name, uuid}, ...]`
- `target_device`**DeviceSlot**,目标设备路径字符串
- `mount_resource`**ResourceSlot**,目标孔位数组 `[{id, name, uuid}, ...]`
---
## 人工确认
### `manual_confirm`
创建人工确认节点,等待用户手动确认后继续(含物料转移上下文)
- **action_type**: `UniLabJsonCommand`
- **Schema**: [`actions/manual_confirm.json`](actions/manual_confirm.json)
- **核心参数**: `resource`, `target_device`, `mount_resource`, `timeout_seconds`, `assignee_user_ids`
- **占位符字段**:
- `resource`**ResourceSlot**,物料数组
- `target_device`**DeviceSlot**,目标设备路径
- `mount_resource`**ResourceSlot**,目标孔位数组
- `assignee_user_ids``unilabos_manual_confirm` 类型

View File

@@ -0,0 +1,270 @@
{
"type": "UniLabJsonCommand",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource",
"timeout_seconds": "timeout_seconds",
"assignee_user_ids": "assignee_user_ids"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
},
"timeout_seconds": {
"type": "integer"
},
"assignee_user_ids": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"resource",
"target_device",
"mount_resource",
"timeout_seconds",
"assignee_user_ids"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources",
"assignee_user_ids": "unilabos_manual_confirm"
}
}

View File

@@ -0,0 +1,19 @@
{
"type": "UniLabJsonCommand",
"goal": {
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"material_number": {
"type": "integer"
}
},
"required": [
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,24 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,20 @@
{
"type": "UniLabJsonCommand",
"goal": {
"count": "count"
},
"schema": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"default": 5
}
},
"required": []
},
"goal_default": {
"count": 5
},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,24 @@
{
"type": "UniLabJsonCommand",
"goal": {
"station_id": "station_id",
"material_number": "material_number"
},
"schema": {
"type": "object",
"properties": {
"station_id": {
"type": "integer"
},
"material_number": {
"type": "integer"
}
},
"required": [
"station_id",
"material_number"
]
},
"goal_default": {},
"placeholder_keys": {}
}

View File

@@ -0,0 +1,255 @@
{
"type": "UniLabJsonCommandAsync",
"goal": {
"resource": "resource",
"target_device": "target_device",
"mount_resource": "mount_resource"
},
"schema": {
"type": "object",
"properties": {
"resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "resource"
},
"type": "array"
},
"target_device": {
"type": "string",
"description": "device reference"
},
"mount_resource": {
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"sample_id": {
"type": "string"
},
"children": {
"type": "array",
"items": {
"type": "string"
}
},
"parent": {
"type": "string"
},
"type": {
"type": "string"
},
"category": {
"type": "string"
},
"pose": {
"type": "object",
"properties": {
"position": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z"
],
"title": "position",
"additionalProperties": false
},
"orientation": {
"type": "object",
"properties": {
"x": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"y": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"z": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
},
"w": {
"type": "number",
"minimum": -1.7976931348623157e+308,
"maximum": 1.7976931348623157e+308
}
},
"required": [
"x",
"y",
"z",
"w"
],
"title": "orientation",
"additionalProperties": false
}
},
"required": [
"position",
"orientation"
],
"title": "pose",
"additionalProperties": false
},
"config": {
"type": "string"
},
"data": {
"type": "string"
}
},
"title": "mount_resource"
},
"type": "array"
}
},
"required": [
"resource",
"target_device",
"mount_resource"
],
"_unilabos_placeholder_info": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
},
"goal_default": {},
"placeholder_keys": {
"resource": "unilabos_resources",
"target_device": "unilabos_devices",
"mount_resource": "unilabos_resources"
}
}

188
.cursorignore Normal file
View File

@@ -0,0 +1,188 @@
# ============================================================
# Uni-Lab-OS Cursor Ignore 配置,控制 Cursor AI 的文件索引范围
# ============================================================
# ==================== 敏感配置文件 ====================
# 本地配置(可能包含密钥)
**/local_config.py
test_config.py
local_test*.py
# 环境变量和密钥
.env
.env.*
**/.certs/
*.pem
*.key
credentials.json
secrets.yaml
# ==================== 二进制和 3D 模型文件 ====================
# 3D 模型文件(无需索引)
*.stl
*.dae
*.glb
*.gltf
*.obj
*.fbx
*.blend
# URDF/Xacro 机器人描述文件大型XML
*.xacro
# 图片文件
*.png
*.jpg
*.jpeg
*.gif
*.webp
*.ico
*.svg
*.bmp
# 压缩包
*.zip
*.tar
*.tar.gz
*.tgz
*.bz2
*.rar
*.7z
# ==================== Python 生成文件 ====================
__pycache__/
*.py[cod]
*$py.class
*.so
*.pyd
*.egg
*.egg-info/
.eggs/
dist/
build/
*.manifest
*.spec
# ==================== IDE 和编辑器 ====================
.idea/
.vscode/
*.swp
*.swo
*~
.#*
# ==================== 测试和覆盖率 ====================
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
coverage.xml
*.cover
# ==================== 虚拟环境 ====================
.venv/
venv/
env/
ENV/
# ==================== ROS 2 生成文件 ====================
# ROS 构建目录
build/
install/
log/
logs/
devel/
# ROS 消息生成
msg_gen/
srv_gen/
msg/*Action.msg
msg/*ActionFeedback.msg
msg/*ActionGoal.msg
msg/*ActionResult.msg
msg/*Feedback.msg
msg/*Goal.msg
msg/*Result.msg
msg/_*.py
srv/_*.py
build_isolated/
devel_isolated/
# ROS 动态配置
*.cfgc
/cfg/cpp/
/cfg/*.py
# ==================== 项目特定目录 ====================
# 工作数据目录
unilabos_data/
# 临时和输出目录
temp/
output/
cursor_docs/
configs/
# 文档构建
docs/_build/
/site
# ==================== 大型数据文件 ====================
# 点云数据
*.pcd
# GraphML 图形文件
*.graphml
# 日志文件
*.log
# 数据库
*.sqlite3
*.db
# Jupyter 检查点
.ipynb_checkpoints/
# ==================== 设备网格资源 ====================
# 3D 网格文件目录(包含大量 STL/DAE 文件)
unilabos/device_mesh/devices/**/*.stl
unilabos/device_mesh/devices/**/*.dae
unilabos/device_mesh/resources/**/*.stl
unilabos/device_mesh/resources/**/*.glb
unilabos/device_mesh/resources/**/*.xacro
# RViz 配置
*.rviz
# ==================== 系统文件 ====================
.DS_Store
Thumbs.db
desktop.ini
# ==================== 锁文件 ====================
poetry.lock
Pipfile.lock
pdm.lock
package-lock.json
yarn.lock
# ==================== 类型检查缓存 ====================
.mypy_cache/
.dmypy.json
.pytype/
.pyre/
pyrightconfig.json
# ==================== 其他 ====================
# Catkin
CATKIN_IGNORE
# Eclipse/Qt
.project
.cproject
CMakeLists.txt.user
*.user
qtcreator-*

11
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,11 @@
## 设备接入
当被要求添加设备驱动时,参考 `docs/ai_guides/add_device.md`
该指南包含完整的模板和已有设备接口参考。
## 关键规则
- 动作方法的参数名是接口契约,不可重命名
- `status` 字符串必须与同类已有设备一致
- `self.data` 必须在 `__init__` 中预填充所有属性字段
- 异步方法中使用 `await self._ros_node.sleep()`,禁止 `time.sleep()`

View File

@@ -12,7 +12,7 @@ Uni-Lab 使用 Python 格式的配置文件(`.py`),默认为 `unilabos_dat
**获取方式:** **获取方式:**
进入 [Uni-Lab 实验室](https://uni-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk 进入 [Uni-Lab 实验室](https://leap-lab.bohrium.com),点击左下角的头像,在实验室详情中获取所在实验室的 ak 和 sk
![copy_aksk.gif](image/copy_aksk.gif) ![copy_aksk.gif](image/copy_aksk.gif)
@@ -69,7 +69,7 @@ class WSConfig:
# HTTP配置 # HTTP配置
class HTTPConfig: class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" # 远程服务器地址 remote_addr = "https://leap-lab.bohrium.com/api/v1" # 远程服务器地址
# ROS配置 # ROS配置
class ROSConfig: class ROSConfig:
@@ -209,8 +209,8 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`--addr` 参数支持以下预设值,会自动转换为对应的完整 URL `--addr` 参数支持以下预设值,会自动转换为对应的完整 URL
- `test``https://uni-lab.test.bohrium.com/api/v1` - `test``https://leap-lab.test.bohrium.com/api/v1`
- `uat``https://uni-lab.uat.bohrium.com/api/v1` - `uat``https://leap-lab.uat.bohrium.com/api/v1`
- `local``http://127.0.0.1:48197/api/v1` - `local``http://127.0.0.1:48197/api/v1`
- 其他值 → 直接使用作为完整 URL - 其他值 → 直接使用作为完整 URL
@@ -248,7 +248,7 @@ unilab --ak "key" --sk "secret" --addr "test" --upload_registry --2d_vis -g grap
`ak``sk` 是必需的认证参数: `ak``sk` 是必需的认证参数:
1. **获取方式**:在 [Uni-Lab 官网](https://uni-lab.bohrium.com) 注册实验室后获得 1. **获取方式**:在 [Uni-Lab 官网](https://leap-lab.bohrium.com) 注册实验室后获得
2. **配置方式** 2. **配置方式**
- **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐) - **命令行参数**`--ak "your_key" --sk "your_secret"`(最高优先级,推荐)
- **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK` - **环境变量**`UNILABOS_BASICCONFIG_AK``UNILABOS_BASICCONFIG_SK`
@@ -275,15 +275,15 @@ WebSocket 是 Uni-Lab 的主要通信方式:
HTTP 客户端配置用于与云端服务通信: HTTP 客户端配置用于与云端服务通信:
| 参数 | 类型 | 默认值 | 说明 | | 参数 | 类型 | 默认值 | 说明 |
| ------------- | ---- | -------------------------------------- | ------------ | | ------------- | ---- | --------------------------------------- | ------------ |
| `remote_addr` | str | `"https://uni-lab.bohrium.com/api/v1"` | 远程服务地址 | | `remote_addr` | str | `"https://leap-lab.bohrium.com/api/v1"` | 远程服务地址 |
**预设环境地址** **预设环境地址**
- 生产环境:`https://uni-lab.bohrium.com/api/v1`(默认) - 生产环境:`https://leap-lab.bohrium.com/api/v1`(默认)
- 测试环境:`https://uni-lab.test.bohrium.com/api/v1` - 测试环境:`https://leap-lab.test.bohrium.com/api/v1`
- UAT 环境:`https://uni-lab.uat.bohrium.com/api/v1` - UAT 环境:`https://leap-lab.uat.bohrium.com/api/v1`
- 本地环境:`http://127.0.0.1:48197/api/v1` - 本地环境:`http://127.0.0.1:48197/api/v1`
### 4. ROSConfig - ROS 配置 ### 4. ROSConfig - ROS 配置
@@ -401,7 +401,7 @@ export UNILABOS_WSCONFIG_RECONNECT_INTERVAL="10"
export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500" export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS="500"
# 设置HTTP配置 # 设置HTTP配置
export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://uni-lab.test.bohrium.com/api/v1" export UNILABOS_HTTPCONFIG_REMOTE_ADDR="https://leap-lab.test.bohrium.com/api/v1"
``` ```
## 配置文件使用方法 ## 配置文件使用方法
@@ -484,13 +484,13 @@ export UNILABOS_WSCONFIG_MAX_RECONNECT_ATTEMPTS=100
```python ```python
class HTTPConfig: class HTTPConfig:
remote_addr = "https://uni-lab.test.bohrium.com/api/v1" remote_addr = "https://leap-lab.test.bohrium.com/api/v1"
``` ```
**环境变量方式:** **环境变量方式:**
```bash ```bash
export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://uni-lab.test.bohrium.com/api/v1 export UNILABOS_HTTPCONFIG_REMOTE_ADDR=https://leap-lab.test.bohrium.com/api/v1
``` ```
**命令行方式(推荐):** **命令行方式(推荐):**

1100
docs/ai_guides/add_device.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
# Uni-Lab-OS 设备接入 Agent — 提示词模板
> 本文件提供一套可直接复制使用的 Agent 系统提示词,以及各平台的配置说明。
> 提示词模板与 `add_device.md`(领域知识)配合使用,前者控制 Agent 行为,后者提供完整的技术细节。
---
## 系统提示词模板
以下内容可直接作为系统提示词 / Instructions / Custom Instructions 使用。`{{...}}` 标记的变量根据平台替换。
---
### 开始复制 ↓
```
你是 Uni-Lab-OS 设备接入专家。你的任务是帮助用户将新的实验室硬件设备接入 Uni-Lab-OS 系统。
你能做的事:
- 根据用户描述生成完整的设备驱动代码Python、注册表YAML和实验图文件JSON
- 解读用户提供的通信协议文档、SDK 代码、或口述的指令格式
- 诊断已有驱动代码的接口对齐问题
你不能做的事:
- 凭空猜测硬件私有通信指令(必须从用户提供的资料中获取)
- 替代真实硬件联调测试
## 知识来源
{{KNOWLEDGE_LOADING}}
## 工作流程
当用户要求接入新设备时,严格按以下流程执行。每个暂停点必须等待用户确认后再继续。
### 阶段 1设备画像交互
向用户收集以下三个信息,可以一次性提问:
1. **设备类别** — 属于以下哪一种?
- temperature温控、pump_and_valve泵阀、motor电机
- heaterstirrer加热搅拌、balance天平、sensor传感器
- liquid_handling液体处理、robot_arm机械臂、workstation工作站
- virtual虚拟设备、custom自定义
- 如果是 pump_and_valve进一步确认子类型注射泵 / 电磁阀 / 蠕动泵
2. **设备英文名称** — 用于文件名和类名(如 my_heater、runze_sy03b
3. **通信协议** — Serial(RS232/RS485) / Modbus RTU / Modbus TCP / TCP Socket / HTTP API / OPC UA / 无通信(虚拟)
⏸️ **暂停:等待用户回答后继续**
### 阶段 2指令协议收集交互
根据上一步确定的通信协议,引导用户提供指令信息:
- 如果用户有 **SDK/驱动代码**:请用户提供代码文件,你从中提取通信逻辑
- 如果用户有 **协议文档**请用户提供文档PDF/图片/文本),你从中解析指令格式
- 如果用户 **口头描述**:针对每个标准动作逐一确认硬件指令
- 如果是 **标准协议**Modbus 寄存器表、SCPI请用户提供寄存器/指令映射
- 如果是 **虚拟设备**:跳过此阶段
⏸️ **暂停:确认已获取足够的指令协议信息**
### 阶段 3确认摘要
在开始生成代码前,向用户展示你的理解摘要:
```
设备接入摘要:
- 设备名称:<name>
- 设备类别:<category><subtype>
- 通信协议:<protocol>
- 指令来源:<source>
- 将要实现的属性:<list>
- 将要实现的动作:<list>
- 同类已有设备:<existing>(将对齐其接口)
```
⏸️ **暂停:用户确认"没问题"后再生成代码**
### 阶段 4自动生成无需暂停
按以下顺序自动执行:
1. **对齐同类设备接口**(指南第四步)
- 查阅指南中的「现有设备接口快照」或搜索仓库注册表
- 确保所有已有设备的 status_types 和动作方法都被覆盖
- 参数名必须完全一致
2. **生成驱动代码** — `unilabos/devices/<category>/<name>.py`
3. **生成注册表** — `unilabos/registry/devices/<name>.yaml`(最小配置)
4. **生成图文件** — `unilabos/test/experiments/graph_example_<name>.json`
### 阶段 5验证输出
生成完成后,逐项检查对齐验证清单并展示结果:
```
对齐验证清单:
- [x] 所有动作方法的参数名与已有设备完全一致
- [x] status 属性返回的字符串值与已有设备一致
- [x] 已有设备的所有 status_types 字段都有对应 @property
- [x] 已有设备的所有非 auto- 前缀的 action 都有对应方法
- [x] self.data 在 __init__ 中已预填充所有属性字段的默认值
- [x] 串口/二进制协议的响应解析先定位帧起始标记
```
如果有未通过的项,主动修复后再展示。
## 硬约束(违反任何一条都会导致设备接入失败)
1. **禁止重命名参数** — 动作方法的参数名(如 volume、position、max_velocity是接口契约框架通过参数名分派调用。绝不能加后缀如 volume_ml、改名如 speed_ml_s。单位写在 docstring 中。
2. **status 字符串必须一致** — 如果同类已有设备用英文(如 "Idle" / "Busy"),新驱动必须用相同的字符串,不能改为中文(如 "就绪")。
3. **self.data 必须预填充** — 不能用空字典 {}。框架在 initialize() 之前就可能读取属性值。每个 @property 对应的键都必须在 __init__ 中有初始值。
4. **禁止跳过接口对齐** — 对齐同类设备接口是强制步骤。缺失的属性和动作会导致设备在工作流中不可互换。
5. **串口解析先找帧头** — RS-485 总线上响应前常有回声/噪声字节。必须先定位帧起始标记(如 /、0xFE禁止用硬编码索引直接解析。
6. **异步等待用 _ros_node.sleep** — 在 async 方法中使用 await self._ros_node.sleep(),禁止 time.sleep()(阻塞事件循环)和 asyncio.sleep()。
7. **物理单位对外暴露** — 对外参数使用用户友好的物理单位mL、°C、RPM驱动内部负责转换到硬件原始值步数、Hz、寄存器值
## 代码骨架参考
所有设备驱动遵循以下结构:
```python
import logging
import time as time_module
from typing import Dict, Any
try:
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
except ImportError:
BaseROS2DeviceNode = None
class MyDevice:
_ros_node: "BaseROS2DeviceNode"
def __init__(self, device_id: str = None, config: Dict[str, Any] = None, **kwargs):
if device_id is None and 'id' in kwargs:
device_id = kwargs.pop('id')
if config is None and 'config' in kwargs:
config = kwargs.pop('config')
self.device_id = device_id or "unknown_device"
self.config = config or {}
self.logger = logging.getLogger(f"MyDevice.{self.device_id}")
self.data = {
"status": "Idle",
# 所有 @property 的键都必须在此预填充
}
def post_init(self, ros_node: "BaseROS2DeviceNode"):
self._ros_node = ros_node
async def initialize(self) -> bool:
self.data["status"] = "Idle"
return True
async def cleanup(self) -> bool:
self.data["status"] = "Offline"
return True
@property
def status(self) -> str:
return self.data.get("status", "Idle")
```
## 注册表最小配置
```yaml
my_device:
class:
module: unilabos.devices.<category>.<file>:MyDevice
type: python
```
启动时 --complete_registry 自动生成 status_types 和 action_value_mappings。
## 图文件模板
```json
{
"nodes": [
{
"id": "my_device_1",
"name": "设备名称",
"children": [],
"parent": null,
"type": "device",
"class": "my_device",
"position": {"x": 0, "y": 0, "z": 0},
"config": {},
"data": {}
}
]
}
```
## 现有设备接口快照(对齐用)
对齐时参考以下已有设备接口。如果能联网,优先从 GitHub 获取最新版本:
https://github.com/dptech-corp/Uni-Lab-OS/tree/main/unilabos/registry/devices/
### pump_and_valve — 注射泵
已有设备syringe_pump_with_valve.runze.SY03B-T06
属性status(str, "Idle"/"Busy"), valve_position(str), position(float, mL), max_velocity(float, mL/s), mode(int), plunger_position(String), velocity_grade(String), velocity_init(String), velocity_end(String)
方法签名(参数名不可改):
- initialize()
- set_valve_position(position)
- set_position(position: float, max_velocity: float = None)
- pull_plunger(volume: float)
- push_plunger(volume: float)
- set_max_velocity(velocity: float)
- set_velocity_grade(velocity)
- stop_operation()
### pump_and_valve — 电磁阀
属性status(str), valve_position(str)
方法open(), close(), set_valve_position(position), is_open(), is_closed()
### temperature
属性status(str), temp(float, °C), temp_target(float, °C), stir_speed(float, RPM), temp_warning(float, °C)
### motor
属性status(str), position(int)
### sensor
属性level(bool), rssi(int)
```
### 结束复制 ↑
---
## `{{KNOWLEDGE_LOADING}}` 变量替换
根据平台能力,将提示词中的 `{{KNOWLEDGE_LOADING}}` 替换为以下对应内容:
### 方案 A有知识库Custom GPT / Claude Project
```
你的知识库中包含 add_device.md 文件,这是完整的设备接入指南。
执行工作流时,参考该文件获取物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
本提示词中的「现有设备接口快照」和「硬约束」是从指南中提炼的关键内容,以确保即使知识库检索不完整也能正确工作。
```
### 方案 B有联网能力
```
执行工作流前,从以下 URL 获取完整的设备接入指南:
https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md
该指南包含物模型模板、通信协议代码片段、指令协议模式和常见错误检查清单。
如果无法访问 URL使用本提示词中内联的「现有设备接口快照」和「代码骨架参考」作为兜底。
```
### 方案 C无知识库、无联网
```
完整的设备接入指南需要用户在对话中提供。
如果用户未主动提供,请在阶段 1 开始前询问:
"请将 add_device.md 的内容粘贴到对话中,或上传该文件。如果没有该文件,我将使用内置的精简规则工作。"
本提示词已内联了最关键的内容(硬约束 + 代码骨架 + 接口快照),足以生成基本正确的驱动。
但完整指南包含更多物模型模板和通信协议代码片段,能显著提升生成质量。
```
---
## 各平台配置指南
### OpenAI Custom GPT
1. 进入 https://chat.openai.com/gpts/editor
2. **Name**Uni-Lab-OS 设备接入助手
3. **Description**:帮助用户将实验室硬件设备接入 Uni-Lab-OS 系统,自动生成驱动代码、注册表和图文件。
4. **Instructions**:粘贴上方系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
5. **Knowledge**:上传 `docs/ai_guides/add_device.md`
6. **Capabilities**:开启 Code Interpreter用于代码验证
7. **Conversation starters**
- "我要接入一个新的注射泵"
- "帮我把这个 SDK 包装成 UniLab 驱动"
- "检查我的设备驱动有没有接口问题"
### Claude Project
1. 创建新 Project
2. **Custom Instructions**:粘贴系统提示词,`{{KNOWLEDGE_LOADING}}` 替换为方案 A
3. **Project Knowledge**:上传 `docs/ai_guides/add_device.md`
### API AgentLangChain / AutoGen / 自建框架)
```python
system_prompt = """
<粘贴完整系统提示词,{{KNOWLEDGE_LOADING}} 替换为方案 B>
"""
# 如果框架支持工具调用,可注册以下工具:
tools = [
{
"name": "fetch_device_guide",
"description": "获取最新的 Uni-Lab-OS 设备接入指南",
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/docs/ai_guides/add_device.md"
},
{
"name": "fetch_registry",
"description": "获取最新的设备注册表",
"url": "https://raw.githubusercontent.com/dptech-corp/Uni-Lab-OS/main/unilabos/registry/devices/{category}.yaml"
},
]
```
### Cursor Agent Mode
无需使用本模板。Cursor 中使用已有的 `.cursor/skills/add-device/SKILL.md`,它会自动读取 `docs/ai_guides/add_device.md` 并利用 Cursor 的工具能力Grep 搜索注册表、AskQuestion 收集信息等)。
### 纯网页对话ChatGPT / Claude 无 Project
1. 第一条消息粘贴系统提示词(`{{KNOWLEDGE_LOADING}}` 替换为方案 C
2. 第二条消息上传或粘贴 `add_device.md`
3. 第三条消息开始描述设备
---
## 维护说明
- **硬约束更新**:如果 `add_device.md` 中新增了禁止事项或常见错误,需要同步更新本模板的「硬约束」部分
- **接口快照更新**:新增设备类别或已有设备接口变更时,需要同步更新本模板的「现有设备接口快照」部分
- **工作流调整**:如果接入流程发生变化(新增步骤、合并步骤),需要同步调整「工作流程」部分
- 本模板与 `add_device.md` 是**互补关系**:模板定义 Agent 行为,指南提供领域知识。两者独立维护

View File

@@ -18,13 +18,15 @@ Uni-Lab 开发团队在仓库中提供了 3 个样例:
- 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py` - 单一机械设备**电夹爪**,通讯协议可见 [增广夹爪通讯协议](https://doc.rmaxis.com/docs/communication/fieldbus/),驱动代码位于 `unilabos/devices/gripper/rmaxis_v4.py`
- 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py` - 单一通信设备**IO 板卡**,驱动代码位于 `unilabos/device_comms/gripper/SRND_16_IO.py`
- 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py` - 执行多设备复杂任务逻辑的**PLC**Uni-Lab 提供了基于地址表的接入方式和点动工作流编写,测试代码位于 `unilabos/device_comms/modbus_plc/test/test_workflow.py`。详细框架说明请参考 {doc}`plc_framework`
--- ---
## 其他工业通信协议CANopen, Ethernet, OPCUA... ## 其他工业通信协议CANopen, Ethernet, OPCUA...
【敬请期待】 Uni-Lab 已实现基于 OPC UA 协议的 PLC 接管框架,用于后处理工站等项目。与 Modbus 框架相比OPC UA 框架额外提供了自动节点发现、订阅推送、断线重连等特性。详细说明请参考 {doc}`plc_framework`
其他协议CANopen、EtherCAT 等)【敬请期待】
## 没有接口的老设备老软件:使用 PyWinAuto ## 没有接口的老设备老软件:使用 PyWinAuto

View File

@@ -23,7 +23,7 @@ Uni-Lab-OS 支持多种部署模式:
``` ```
┌──────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
│ Cloud Platform/Self-hosted Platform │ │ Cloud Platform/Self-hosted Platform │
uni-lab.bohrium.com │ leap-lab.bohrium.com │
│ (Resource Management, Task Scheduling, │ │ (Resource Management, Task Scheduling, │
│ Monitoring) │ │ Monitoring) │
└────────────────────┬─────────────────────────┘ └────────────────────┬─────────────────────────┘
@@ -444,7 +444,7 @@ ros2 daemon stop && ros2 daemon start
```bash ```bash
# 测试云端连接 # 测试云端连接
curl https://uni-lab.bohrium.com/api/v1/health curl https://leap-lab.bohrium.com/api/v1/health
# 测试WebSocket # 测试WebSocket
# 启动Uni-Lab后查看日志 # 启动Uni-Lab后查看日志

View File

@@ -0,0 +1,281 @@
# PLC 设备接管框架
> 本文档面向初次接触 UniLab-OS 的开发者,介绍系统如何通过工业协议"接管"连接并控制PLC 设备。
## 什么是"PLC 接管"
**PLC**可编程逻辑控制器是工厂设备的控制核心驱动机械臂、泵、阀门等硬件。UniLab-OS 通过网络协议直接读写 PLC 内部变量,从而控制设备运行:
```
UniLab-OSPython ←通信协议→ PLC ←电信号→ 电机/气缸/传感器
```
UniLab-OS 提供两套接管框架,对应两种工业协议:
| 框架 | 协议 | 应用项目 | 核心文件 |
| --------------- | ---------------- | ------------------ | ----------------------------------------------------------- |
| **Modbus 框架** | Modbus TCP / RTU | 扣式电池装配工站 | `unilabos/device_comms/modbus_plc/client.py` |
| **OPC UA 框架** | OPC UA | 后处理工站(怀柔) | `unilabos/devices/workstation/post_process/post_process.py` |
两套框架**设计思想完全一致**,底层通信协议不同。理解一个,另一个基本触类旁通。
---
## 核心概念
### 节点Node
节点是 PLC 内部一个具体的变量地址,可以理解为 PLC 的一个输入/输出端口。
| 属性 | 说明 | 示例 |
| ---- | -------------------------------------- | -------------------- |
| 名称 | 人类可读标识 | `COIL_SYS_START_CMD` |
| 地址 | PLC 内存地址 | `0x0064` |
| 类型 | Coil / HoldRegister / InputRegister 等 | `coil` |
```
PLC 内存空间
├── Coil 区: True / False ← 控制开关量(启动/停止/复位)
├── Hold Reg: 120, 35.5 … ← 存参数值(速度、位置)
└── Input Reg: 99.8, 42 … ← 只读传感器数据
```
### 动作生命周期Action Lifecycle
每个设备动作被拆分为 4 个阶段,用 `try/finally` 保证安全性:
```python
try:
init(...) # 写入参数(速度、位置等)— 可选
start(...) # 发触发信号 + 轮询等待完成
stop(...) # 复位触发信号(成功时执行)
except:
is_err = True
finally:
cleanup(...) # 无论成败都执行,作为安全兜底
```
| 阶段 | 何时执行 | 典型内容 |
| --------- | ----------------------- | ------------------------------------ |
| `init` | 成功路径(可选) | 写运动速度 = 20.0 |
| `start` | 成功路径 | 写启动位 = True等待完成位 = True |
| `stop` | 成功路径 | 写启动位 = False正常复位 |
| `cleanup` | **无论成败**finally | 安全兜底复位,防止异常时设备持续运动 |
> **为什么 `cleanup` 无论成败都执行?**
> 若 `start` 阶段因传感器故障抛出异常,`stop` 会被跳过PLC 触发位仍为 `True`——设备可能持续运动。`cleanup` 放在 `finally` 块中,作为最后的安全保障,确保 PLC 一定被复位到安全状态。实际上大多数动作将 `cleanup` 设为 `null`,由 `stop` 负责正常复位即可。
---
## Modbus 框架
**核心文件**`unilabos/device_comms/modbus_plc/client.py`
**参考实现**`unilabos/devices/workstation/coin_cell_assembly/coin_cell_assembly.py`
### 连接与节点注册
```python
from unilabos.device_comms.modbus_plc.client import TCPClient, BaseClient
# 1. 建立 TCP 连接
client = TCPClient(addr="172.16.28.102", port=502)
client.client.connect()
# 2. 从 CSV 加载节点定义
nodes = BaseClient.load_csv("coin_cell_assembly_b.csv")
# 3. 注册节点,之后可按名称访问
client.register_node_list(nodes)
# 访问节点
client.use_node('COIL_SYS_START_CMD').write(True)
value, err = client.use_node('COIL_SYS_START_STATUS').read(1)
```
**CSV 格式**`Name` / `DeviceType` / `Address` / `DataType`
| Name | DeviceType | Address | DataType |
| ------------------ | ------------- | ------- | -------- |
| COIL_SYS_START_CMD | coil | 100 | INT16 |
| REG_SPEED | hold_register | 200 | FLOAT32 |
### 三段式接管流程(扣式电池工站)
PLC 设备通常需要按固定顺序切换模式,以扣式电池工站为例:
```
Python PLC
│── 写 HAND_CMD = True ─────────>│ 切换到手动模式
│<─ 读 HAND_STATUS = True ────────│ 确认进入手动
│── 写 INIT_CMD = True ──────────>│ 执行初始化
│<─ 读 INIT_STATUS = True ─────────│ 初始化完成
│── 写 HAND_CMD = False ──────────>│ 复位(脉冲信号)
│── 写 INIT_CMD = False ──────────>│ 复位
│── 写 AUTO_CMD = True ───────────>│ 切换自动模式
│<─ 读 AUTO_STATUS = True ─────────│ 自动模式就绪
│── 写 AUTO_CMD = False ──────────>│ 复位
│── 写 START_CMD = True ──────────>│ 开始运行
│<─ 读 START_STATUS = True ────────│ 运行确认
│── 写 START_CMD = False ──────────>│ 复位
```
> **脉冲信号模式**:命令写 `True` → 等待 PLC 状态位确认 → 命令写回 `False`,这是大多数 PLC 的标准触发方式,而不是保持高电平。
### JSON 配置方式
Modbus 框架支持纯 JSON 配置,无需手写 Python 流程:
```json
{
"register_node_list_from_csv_path": {"path": "M01.csv"},
"create_flow": [
{
"name": "归位",
"action": [{
"address_function_to_create": [
{"func_name": "pos_tip", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": true},
{"func_name": "pos_tip_read", "node_name": "M01_idlepos_coil_r", "mode": "read", "value": 1},
{"func_name": "manual_stop", "node_name": "M01_manual_stop_coil_r","mode": "read", "value": 1}
],
"create_init_function": {"func_name": "idel_init", "node_name": "M01_idlepos_velocity_rw", "mode": "write", "value": 20.0},
"create_start_function": {
"func_name": "idel_position",
"write_functions": ["pos_tip"],
"condition_functions": ["pos_tip_read", "manual_stop"],
"stop_condition_expression": "pos_tip_read[0] and manual_stop[0]"
},
"create_stop_function": {"func_name": "idel_stop", "node_name": "M01_idlepos_coil_w", "mode": "write", "value": false},
"create_cleanup_function": null
}]
}
],
"execute_flow": ["归位"]
}
```
执行:
```python
client.execute_procedure_from_json(json_data)
```
---
## OPC UA 框架
**核心文件**`unilabos/devices/workstation/post_process/post_process.py`
**参考实现**`unilabos/devices/workstation/post_process/opcua_huairou.json`
### 与 Modbus 的主要区别
| 特性 | Modbus | OPC UA |
| ---------- | -------------------- | --------------------------------- |
| 节点发现 | 手动填写 CSV 地址 | **自动遍历**服务器节点树 |
| 数据获取 | 轮询(主动问) | **订阅推送**(有变化时通知) |
| 节点标识 | 数字地址(如 `100` | 字符串 NodeId`ns=2;s=速度` |
| 断线处理 | 无 | **后台监控线程**自动重连 |
| 认证安全 | 无 | 支持用户名/密码 |
| 工作流调用 | 手动调用 | **自动注册为实例方法** |
### 连接与节点发现
```python
from unilabos.devices.workstation.post_process.post_process import OpcUaClient
client = OpcUaClient(
url="opc.tcp://192.168.1.100:4840",
username="admin", # 可选
password="123456", # 可选
config_path="opcua_huairou.json", # 自动加载工作流配置
cache_timeout=5.0, # 节点值缓存 5 秒
subscription_interval=500, # 每 500ms 接收推送
)
# 节点自动通过订阅保持最新值,读取直接查本地缓存
value = client.get_node_value("grab_complete")
```
### JSON 配置方式
```json
{
"register_node_list_from_csv_path": {"path": "opcua_nodes_huairou.csv"},
"create_flow": [
{
"name": "trigger_grab_action",
"description": "触发反应罐及原料罐抓取动作",
"parameters": ["reaction_tank_number", "raw_tank_number"],
"action": [{
"init_function": {
"func_name": "init_grab_params",
"write_nodes": ["reaction_tank_number", "raw_tank_number"]
},
"start_function": {
"func_name": "start_grab",
"write_nodes": {"grab_trigger": true},
"condition_nodes": ["grab_complete"],
"stop_condition_expression": "grab_complete == True",
"timeout_seconds": 999999.0
},
"stop_function": {
"func_name": "stop_grab",
"write_nodes": {"grab_trigger": false}
}
}]
}
]
}
```
配置加载后,工作流自动注册为实例方法:
```python
# 直接调用,传入参数,框架自动写入对应节点
client.trigger_grab_action(reaction_tank_number=2, raw_tank_number=3)
```
---
## 新增设备快速上手
### 使用 Modbus 框架
```
1. 从 PLC 工程师处拿到地址表,按格式填写 CSVName/DeviceType/Address/DataType
2. 继承 BaseClient在 __init__ 中连接并注册节点
3. 参考 coin_cell_assembly.py 编写三段式接管函数(手动→初始化→自动→启动)
4. 或直接编写 JSON 配置,调用 execute_procedure_from_json()
```
### 使用 OPC UA 框架
```
1. 确认设备支持 OPC UA拿到服务器 URL格式opc.tcp://IP:PORT
2. 准备 CSV 节点定义文件(可选,也可让框架自动发现)
3. 编写 JSON 配置:定义 parameters、init/start/stop 函数
4. 实例化 OpcUaClient传入 config_path直接调用自动注册的工作流方法
```
---
## 常见问题
**Q: `node {name} is not registered` 报错?**
A: 节点名不在 CSV 或未调用 `register_node_list_from_csv_path()`
**Q: 程序卡死在 `while not status(): sleep(1)`**
A: PLC 未返回预期完成信号。检查PLC 是否在正确运行模式、状态位地址是否正确、PLC 有无报警。
**Q: OPC UA 连接成功但读不到节点?**
A: 检查节点名称是否与服务器显示名一致(区分中英文)。可调用 `_find_nodes()` 打印服务器全部节点。
**Q: 应该选 Modbus 还是 OPC UA**
A: 取决于设备支持的协议,由 PLC 工程师决定。OPC UA 功能更完整,条件允许优先选择。
---
## 下一步
- {doc}`add_device` - 将驱动集成进 UniLab-OS 设备节点
- {doc}`add_action` - 为设备添加可调度的动作指令
- {doc}`add_yaml` - 编写设备注册表 YAML 文件

View File

@@ -17,6 +17,9 @@ developer_guide/http_api.md
developer_guide/networking_overview.md developer_guide/networking_overview.md
developer_guide/add_device.md developer_guide/add_device.md
developer_guide/add_action.md developer_guide/add_action.md
developer_guide/add_old_device.md
developer_guide/plc_framework.md
developer_guide/add_protocol.md
developer_guide/add_registry.md developer_guide/add_registry.md
developer_guide/add_yaml.md developer_guide/add_yaml.md
developer_guide/action_includes.md developer_guide/action_includes.md

View File

@@ -33,11 +33,11 @@
**选择合适的安装包:** **选择合适的安装包:**
| 安装包 | 适用场景 | 包含组件 | | 安装包 | 适用场景 | 包含组件 |
|--------|----------|----------| | --------------- | ---------------------------- | --------------------------------------------- |
| `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 | | `unilabos` | **推荐大多数用户**,生产部署 | 完整安装包,开箱即用 |
| `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos | | `unilabos-env` | 开发者(可编辑安装) | 仅环境依赖,通过 pip 安装 unilabos |
| `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt | | `unilabos-full` | 仿真/可视化 | unilabos + 完整 ROS2 桌面版 + Gazebo + MoveIt |
**关键步骤:** **关键步骤:**
@@ -66,6 +66,7 @@ mamba install uni-lab::unilabos-full -c robostack-staging -c conda-forge
``` ```
**选择建议:** **选择建议:**
- **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用 - **日常使用/生产部署**:使用 `unilabos`(推荐),完整功能,开箱即用
- **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效 - **开发者**:使用 `unilabos-env` + `pip install -e .` + `uv pip install -r unilabos/utils/requirements.txt`,代码修改立即生效
- **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt - **仿真/可视化**:使用 `unilabos-full`,含 Gazebo、rviz2、MoveIt
@@ -88,7 +89,7 @@ python -c "from unilabos_msgs.msg import Resource; print('ROS msgs OK')"
#### 2.1 注册实验室账号 #### 2.1 注册实验室账号
1. 访问 [https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) 1. 访问 [https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
2. 注册账号并登录 2. 注册账号并登录
3. 创建新实验室 3. 创建新实验室
@@ -297,7 +298,7 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
#### 5.2 访问 Web 界面 #### 5.2 访问 Web 界面
启动系统后,访问[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) 启动系统后,访问[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
#### 5.3 添加设备和物料 #### 5.3 添加设备和物料
@@ -306,12 +307,10 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
**示例场景:** 创建一个简单的液体转移实验 **示例场景:** 创建一个简单的液体转移实验
1. **添加工作站(必需):** 1. **添加工作站(必需):**
- 在"仪器设备"中找到 `work_station` - 在"仪器设备"中找到 `work_station`
- 添加 `workstation` x1 - 添加 `workstation` x1
2. **添加虚拟转移泵:** 2. **添加虚拟转移泵:**
- 在"仪器设备"中找到 `virtual_device` - 在"仪器设备"中找到 `virtual_device`
- 添加 `virtual_transfer_pump` x1 - 添加 `virtual_transfer_pump` x1
@@ -818,6 +817,7 @@ uv pip install -r unilabos/utils/requirements.txt
``` ```
**为什么使用这种方式?** **为什么使用这种方式?**
- `unilabos-env` 提供 ROS2 核心组件和 uv通过 conda 安装,避免编译) - `unilabos-env` 提供 ROS2 核心组件和 uv通过 conda 安装,避免编译)
- `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖 - `unilabos/utils/requirements.txt` 包含所有运行时需要的 pip 依赖
- `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像 - `dev_install.py` 自动检测中文环境,中文系统自动使用清华镜像
@@ -1796,32 +1796,27 @@ unilab --ak your_ak --sk your_sk -g graph.json \
**详细步骤:** **详细步骤:**
1. **需求分析** 1. **需求分析**
- 明确实验流程 - 明确实验流程
- 列出所需设备和物料 - 列出所需设备和物料
- 设计工作流程图 - 设计工作流程图
2. **环境搭建** 2. **环境搭建**
- 安装 Uni-Lab-OS - 安装 Uni-Lab-OS
- 创建实验室账号 - 创建实验室账号
- 准备开发工具IDE、Git - 准备开发工具IDE、Git
3. **原型验证** 3. **原型验证**
- 使用虚拟设备测试流程 - 使用虚拟设备测试流程
- 验证工作流逻辑 - 验证工作流逻辑
- 调整参数 - 调整参数
4. **迭代开发** 4. **迭代开发**
- 实现自定义设备驱动(同时撰写单点函数测试) - 实现自定义设备驱动(同时撰写单点函数测试)
- 编写注册表 - 编写注册表
- 单元测试 - 单元测试
- 集成测试 - 集成测试
5. **测试部署** 5. **测试部署**
- 连接真实硬件 - 连接真实硬件
- 空跑测试 - 空跑测试
- 小规模试验 - 小规模试验
@@ -1871,7 +1866,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/deepmodeling/Uni-Lab-OS/issues](https://github.com/deepmodeling/Uni-Lab-OS/issues)
- **官方网站**[https://uni-lab.bohrium.com](https://uni-lab.bohrium.com) - **官方网站**[https://leap-lab.bohrium.com](https://leap-lab.bohrium.com)
--- ---

View File

@@ -626,7 +626,7 @@ unilab
**云端图文件管理**: **云端图文件管理**:
1. 登录 https://uni-lab.bohrium.com 1. 登录 https://leap-lab.bohrium.com
2. 进入"设备配置" 2. 进入"设备配置"
3. 创建或编辑配置 3. 创建或编辑配置
4. 保存到云端 4. 保存到云端

View File

@@ -54,7 +54,6 @@ Uni-Lab 的启动过程分为以下几个阶段:
您可以直接跟随 unilabos 的提示进行,无需查阅本节 您可以直接跟随 unilabos 的提示进行,无需查阅本节
- **工作目录设置** - **工作目录设置**
- 如果当前目录以 `unilabos_data` 结尾,则使用当前目录 - 如果当前目录以 `unilabos_data` 结尾,则使用当前目录
- 否则使用 `当前目录/unilabos_data` 作为工作目录 - 否则使用 `当前目录/unilabos_data` 作为工作目录
- 可通过 `--working_dir` 指定自定义工作目录 - 可通过 `--working_dir` 指定自定义工作目录
@@ -68,8 +67,8 @@ Uni-Lab 的启动过程分为以下几个阶段:
支持多种后端环境: 支持多种后端环境:
- `--addr test`:测试环境 (`https://uni-lab.test.bohrium.com/api/v1`) - `--addr test`:测试环境 (`https://leap-lab.test.bohrium.com/api/v1`)
- `--addr uat`UAT 环境 (`https://uni-lab.uat.bohrium.com/api/v1`) - `--addr uat`UAT 环境 (`https://leap-lab.uat.bohrium.com/api/v1`)
- `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`) - `--addr local`:本地环境 (`http://127.0.0.1:48197/api/v1`)
- 自定义地址:直接指定完整 URL - 自定义地址:直接指定完整 URL
@@ -176,7 +175,7 @@ unilab --config path/to/your/config.py
如果是首次使用,系统会: 如果是首次使用,系统会:
1. 提示前往 https://uni-lab.bohrium.com 注册实验室 1. 提示前往 https://leap-lab.bohrium.com 注册实验室
2. 引导创建配置文件 2. 引导创建配置文件
3. 设置工作目录 3. 设置工作目录
@@ -216,7 +215,7 @@ unilab --ak your_ak --sk your_sk --port 8080 --disable_browser
如果提示 "后续运行必须拥有一个实验室",请确保: 如果提示 "后续运行必须拥有一个实验室",请确保:
- 已在 https://uni-lab.bohrium.com 注册实验室 - 已在 https://leap-lab.bohrium.com 注册实验室
- 正确设置了 `--ak``--sk` 参数 - 正确设置了 `--ak``--sk` 参数
- 配置文件中包含正确的认证信息 - 配置文件中包含正确的认证信息

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
__version__ = "0.10.19" __version__ = "0.11.1"

View File

@@ -12,6 +12,15 @@ from typing import Dict, Any, List
import networkx as nx import networkx as nx
import yaml import yaml
# Windows 中文系统 stdout 默认 GBK无法编码 banner / emoji 日志中的 Unicode 字符
# 强制 stdout/stderr 用 UTF-8避免 print 触发 UnicodeEncodeError 导致进程崩溃
if sys.platform == "win32":
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except (AttributeError, OSError):
pass
# 首先添加项目根目录到路径 # 首先添加项目根目录到路径
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
unilabos_dir = os.path.dirname(os.path.dirname(current_dir)) unilabos_dir = os.path.dirname(os.path.dirname(current_dir))
@@ -233,7 +242,7 @@ def parse_args():
parser.add_argument( parser.add_argument(
"--addr", "--addr",
type=str, type=str,
default="https://uni-lab.bohrium.com/api/v1", default="https://leap-lab.bohrium.com/api/v1",
help="Laboratory backend address", help="Laboratory backend address",
) )
parser.add_argument( parser.add_argument(
@@ -264,6 +273,12 @@ def parse_args():
default=False, default=False,
help="Test mode: all actions simulate execution and return mock results without running real hardware", 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( parser.add_argument(
"--extra_resource", "--extra_resource",
action="store_true", action="store_true",
@@ -342,11 +357,18 @@ def main():
check_mode = args_dict.get("check_mode", False) check_mode = args_dict.get("check_mode", False)
if not skip_env_check: if not skip_env_check:
from unilabos.utils.environment_check import check_environment from unilabos.utils.environment_check import check_environment, check_device_package_requirements
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")
@@ -425,10 +447,10 @@ def main():
if args.addr != parser.get_default("addr"): if args.addr != parser.get_default("addr"):
if args.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://leap-lab.test.bohrium.com/api/v1"
elif args.addr == "uat": elif args.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://leap-lab.uat.bohrium.com/api/v1"
elif args.addr == "local": elif args.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"
@@ -477,19 +499,7 @@ def main():
BasicConfig.vis_2d_enable = args_dict["2d_vis"] BasicConfig.vis_2d_enable = args_dict["2d_vis"]
BasicConfig.check_mode = check_mode BasicConfig.check_mode = check_mode
from unilabos.resources.graphio import (
read_node_link_json,
read_graphml,
dict_from_graph,
)
from unilabos.app.communication import get_communication_client
from unilabos.registry.registry import build_registry from unilabos.registry.registry import build_registry
from unilabos.app.backend import start_backend
from unilabos.app.web import http_client
from unilabos.app.web import start_server
from unilabos.app.register import register_devices_and_resources
from unilabos.resources.graphio import modify_to_backend_format
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
# 显示启动横幅 # 显示启动横幅
print_unilab_banner(args_dict) print_unilab_banner(args_dict)
@@ -498,12 +508,14 @@ def main():
# check_mode 和 upload_registry 都会执行实际 import 验证 # check_mode 和 upload_registry 都会执行实际 import 验证
devices_dirs = args_dict.get("devices", None) devices_dirs = args_dict.get("devices", None)
complete_registry = args_dict.get("complete_registry", False) or check_mode complete_registry = args_dict.get("complete_registry", False) or check_mode
external_only = args_dict.get("external_devices_only", False)
lab_registry = build_registry( lab_registry = build_registry(
registry_paths=args_dict["registry_path"], registry_paths=args_dict["registry_path"],
devices_dirs=devices_dirs, devices_dirs=devices_dirs,
upload_registry=BasicConfig.upload_registry, upload_registry=BasicConfig.upload_registry,
check_mode=check_mode, check_mode=check_mode,
complete_registry=complete_registry, complete_registry=complete_registry,
external_only=external_only,
) )
# Check mode: 注册表验证完成后直接退出 # Check mode: 注册表验证完成后直接退出
@@ -513,6 +525,20 @@ def main():
print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info") print_status(f"Check mode: 注册表验证完成 ({device_count} 设备, {resource_count} 资源),退出", "info")
os._exit(0) os._exit(0)
# 以下导入依赖 ROS2 环境check_mode 已退出不需要
from unilabos.resources.graphio import (
read_node_link_json,
read_graphml,
dict_from_graph,
modify_to_backend_format,
)
from unilabos.app.communication import get_communication_client
from unilabos.app.backend import start_backend
from unilabos.app.web import http_client
from unilabos.app.web import start_server
from unilabos.app.register import register_devices_and_resources
from unilabos.resources.resource_tracker import ResourceTreeSet, ResourceDict
# Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data # Step 1: 上传全部注册表到服务端,同步保存到 unilabos_data
if BasicConfig.upload_registry: if BasicConfig.upload_registry:
if BasicConfig.ak and BasicConfig.sk: if BasicConfig.ak and BasicConfig.sk:
@@ -536,7 +562,7 @@ def main():
os._exit(0) 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://leap-lab.bohrium.com 注册实验室!", "warning")
os._exit(1) os._exit(1)
graph: nx.Graph graph: nx.Graph
resource_tree_set: ResourceTreeSet resource_tree_set: ResourceTreeSet
@@ -610,6 +636,10 @@ def main():
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

View File

@@ -36,6 +36,9 @@ class HTTPClient:
auth_secret = BasicConfig.auth_secret() auth_secret = BasicConfig.auth_secret()
self.auth = auth_secret self.auth = auth_secret
info(f"正在使用ak sk作为授权信息[{auth_secret}]") info(f"正在使用ak sk作为授权信息[{auth_secret}]")
# 复用 TCP/TLS 连接,避免每次请求重新握手
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Lab {self.auth}"})
info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}") info(f"HTTPClient 初始化完成: remote_addr={self.remote_addr}")
def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response: def resource_edge_add(self, resources: List[Dict[str, Any]]) -> requests.Response:
@@ -48,7 +51,7 @@ class HTTPClient:
Returns: Returns:
Response: API响应对象 Response: API响应对象
""" """
response = requests.post( response = self._session.post(
f"{self.remote_addr}/edge/material/edge", f"{self.remote_addr}/edge/material/edge",
json={ json={
"edges": resources, "edges": resources,
@@ -75,25 +78,28 @@ class HTTPClient:
Returns: Returns:
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: # dump() 只调用一次,复用给文件保存和 HTTP 请求
payload = {"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid} nodes_info = [x for xs in resources.dump() for x in xs]
f.write(json.dumps(payload, indent=4))
# 从序列化数据中提取所有节点的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}
payload = {"nodes": nodes_info, "mount_uuid": mount_uuid}
body_bytes = _fast_dumps(payload)
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_add.json"), "wb") as f:
f.write(_fast_dumps_pretty(payload))
http_headers = {"Content-Type": "application/json"}
if not self.initialized or first_add: if not self.initialized or first_add:
self.initialized = True self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}") info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post( response = self._session.post(
f"{self.remote_addr}/edge/material", f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, data=body_bytes,
headers={"Authorization": f"Lab {self.auth}"}, headers=http_headers,
timeout=60, timeout=60,
) )
else: else:
response = requests.put( response = self._session.put(
f"{self.remote_addr}/edge/material", f"{self.remote_addr}/edge/material",
json={"nodes": [x for xs in resources.dump() for x in xs], "mount_uuid": mount_uuid}, data=body_bytes,
headers={"Authorization": f"Lab {self.auth}"}, headers=http_headers,
timeout=10, timeout=10,
) )
@@ -111,6 +117,7 @@ class HTTPClient:
uuid_mapping[i["uuid"]] = i["cloud_uuid"] uuid_mapping[i["uuid"]] = i["cloud_uuid"]
else: else:
logger.error(f"添加物料失败: {response.text}") logger.error(f"添加物料失败: {response.text}")
logger.trace(f"添加物料失败: {nodes_info}")
for u, n in old_uuids.items(): for u, n in old_uuids.items():
if u in uuid_mapping: if u in uuid_mapping:
n.res_content.uuid = uuid_mapping[u] n.res_content.uuid = uuid_mapping[u]
@@ -131,7 +138,7 @@ class HTTPClient:
""" """
with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f: with open(os.path.join(BasicConfig.working_dir, "req_resource_tree_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4)) f.write(json.dumps({"uuids": uuid_list, "with_children": with_children}, indent=4))
response = requests.post( response = self._session.post(
f"{self.remote_addr}/edge/material/query", f"{self.remote_addr}/edge/material/query",
json={"uuids": uuid_list, "with_children": with_children}, json={"uuids": uuid_list, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -145,6 +152,7 @@ class HTTPClient:
logger.error(f"查询物料失败: {response.text}") logger.error(f"查询物料失败: {response.text}")
else: else:
data = res["data"]["nodes"] data = res["data"]["nodes"]
logger.trace(f"resource_tree_get查询到物料: {data}")
return data return data
else: else:
logger.error(f"查询物料失败: {response.text}") logger.error(f"查询物料失败: {response.text}")
@@ -162,14 +170,14 @@ class HTTPClient:
if not self.initialized: if not self.initialized:
self.initialized = True self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}") info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post( response = self._session.post(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
json={"nodes": resources}, json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
timeout=100, timeout=100,
) )
else: else:
response = requests.put( response = self._session.put(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
json={"nodes": resources}, json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -196,7 +204,7 @@ class HTTPClient:
""" """
with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f: with open(os.path.join(BasicConfig.working_dir, "req_resource_get.json"), "w", encoding="utf-8") as f:
f.write(json.dumps({"id": id, "with_children": with_children}, indent=4)) f.write(json.dumps({"id": id, "with_children": with_children}, indent=4))
response = requests.get( response = self._session.get(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
params={"id": id, "with_children": with_children}, params={"id": id, "with_children": with_children},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -237,14 +245,14 @@ class HTTPClient:
if not self.initialized: if not self.initialized:
self.initialized = True self.initialized = True
info(f"首次添加资源,当前远程地址: {self.remote_addr}") info(f"首次添加资源,当前远程地址: {self.remote_addr}")
response = requests.post( response = self._session.post(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
json={"nodes": resources}, json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
timeout=100, timeout=100,
) )
else: else:
response = requests.put( response = self._session.put(
f"{self.remote_addr}/lab/material", f"{self.remote_addr}/lab/material",
json={"nodes": resources}, json={"nodes": resources},
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -274,7 +282,7 @@ class HTTPClient:
with open(file_path, "rb") as file: with open(file_path, "rb") as file:
files = {"files": file} files = {"files": file}
logger.info(f"上传文件: {file_path}{scene}") logger.info(f"上传文件: {file_path}{scene}")
response = requests.post( response = self._session.post(
f"{self.remote_addr}/api/account/file_upload/{scene}", f"{self.remote_addr}/api/account/file_upload/{scene}",
files=files, files=files,
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
@@ -314,7 +322,7 @@ class HTTPClient:
"Content-Type": "application/json", "Content-Type": "application/json",
"Content-Encoding": "gzip", "Content-Encoding": "gzip",
} }
response = requests.post( response = self._session.post(
f"{self.remote_addr}/lab/resource", f"{self.remote_addr}/lab/resource",
data=compressed_body, data=compressed_body,
headers=headers, headers=headers,
@@ -348,7 +356,7 @@ class HTTPClient:
Returns: Returns:
Response: API响应对象 Response: API响应对象
""" """
response = requests.get( response = self._session.get(
f"{self.remote_addr}/edge/material/download", f"{self.remote_addr}/edge/material/download",
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},
timeout=(3, 30), timeout=(3, 30),
@@ -409,7 +417,7 @@ class HTTPClient:
with open(os.path.join(BasicConfig.working_dir, "req_workflow_upload.json"), "w", encoding="utf-8") as f: 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)) f.write(json.dumps(payload, indent=4, ensure_ascii=False))
response = requests.post( response = self._session.post(
f"{self.remote_addr}/lab/workflow/owner/import", f"{self.remote_addr}/lab/workflow/owner/import",
json=payload, json=payload,
headers={"Authorization": f"Lab {self.auth}"}, headers={"Authorization": f"Lab {self.auth}"},

View File

@@ -754,6 +754,32 @@ class MessageProcessor:
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)
# 服务端对always_free动作可能跳过query_action_state直接发job_start
# 此时job尚未注册需要自动补注册
existing_job = self.device_manager.get_job_info(req.job_id)
if not existing_job:
action_name = req.action
device_action_key = f"/devices/{req.device_id}/{action_name}"
action_always_free = self._check_action_always_free(req.device_id, action_name)
if action_always_free:
job_info = JobInfo(
job_id=req.job_id,
task_id=req.task_id,
device_id=req.device_id,
action_name=action_name,
device_action_key=device_action_key,
status=JobStatus.QUEUE,
start_time=time.time(),
always_free=True,
)
self.device_manager.add_queue_request(job_info)
logger.info(f"[MessageProcessor] Job {job_log} always_free, auto-registered from direct job_start")
else:
logger.error(f"[MessageProcessor] Job {job_log} not registered (missing query_action_state)")
return
success = self.device_manager.start_job(req.job_id) success = self.device_manager.start_job(req.job_id)
if not success: if not success:
logger.error(f"[MessageProcessor] Failed to start job {job_log}") logger.error(f"[MessageProcessor] Failed to start job {job_log}")
@@ -1087,7 +1113,7 @@ class MessageProcessor:
"task_id": task_id, "task_id": task_id,
"job_id": job_id, "job_id": job_id,
"free": free, "free": free,
"need_more": need_more, "need_more": need_more + 1,
}, },
} }
@@ -1227,7 +1253,7 @@ class QueueProcessor:
"task_id": job_info.task_id, "task_id": job_info.task_id,
"job_id": job_info.job_id, "job_id": job_info.job_id,
"free": False, "free": False,
"need_more": 10, "need_more": 10 + 1,
}, },
} }
self.message_processor.send_message(message) self.message_processor.send_message(message)
@@ -1243,7 +1269,13 @@ class QueueProcessor:
if not queued_jobs: if not queued_jobs:
return return
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs") queue_summary = {}
for j in queued_jobs:
key = f"{j.device_id}/{j.action_name}"
queue_summary[key] = queue_summary.get(key, 0) + 1
logger.debug(
f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs: {queue_summary}"
)
for job_info in queued_jobs: for job_info in queued_jobs:
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY # 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY
@@ -1260,7 +1292,7 @@ class QueueProcessor:
"task_id": job_info.task_id, "task_id": job_info.task_id,
"job_id": job_info.job_id, "job_id": job_info.job_id,
"free": False, "free": False,
"need_more": 10, "need_more": 10 + 1,
}, },
} }
success = self.message_processor.send_message(message) success = self.message_processor.send_message(message)
@@ -1343,6 +1375,10 @@ class WebSocketClient(BaseCommunicationClient):
self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager) self.message_processor = MessageProcessor(self.websocket_url, self.send_queue, self.device_manager)
self.queue_processor = QueueProcessor(self.device_manager, self.message_processor) self.queue_processor = QueueProcessor(self.device_manager, self.message_processor)
# running状态debounce缓存: {job_id: (last_send_timestamp, last_feedback_data)}
self._job_running_last_sent: Dict[str, tuple] = {}
self._job_running_debounce_interval: float = 10.0 # 秒
# 设置相互引用 # 设置相互引用
self.message_processor.set_queue_processor(self.queue_processor) self.message_processor.set_queue_processor(self.queue_processor)
self.message_processor.set_websocket_client(self) self.message_processor.set_websocket_client(self)
@@ -1442,22 +1478,32 @@ class WebSocketClient(BaseCommunicationClient):
logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}") logger.debug(f"[WebSocketClient] Not connected, cannot publish job status for job_id: {item.job_id}")
return return
job_log = format_job_log(item.job_id, item.task_id, item.device_id, item.action_name)
# 拦截最终结果状态,与原版本逻辑一致 # 拦截最终结果状态,与原版本逻辑一致
if status in ["success", "failed"]: if status in ["success", "failed"]:
self._job_running_last_sent.pop(item.job_id, None)
host_node = HostNode.get_instance(0) host_node = HostNode.get_instance(0)
if host_node: if host_node:
# 从HostNode的device_action_status中移除job_id
try: try:
host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None) host_node._device_action_status[item.device_action_key].job_ids.pop(item.job_id, None)
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}")
# 通知队列处理器job完成包括timeout的job
self.queue_processor.handle_job_completed(item.job_id, status) self.queue_processor.handle_job_completed(item.job_id, status)
# 发送job状态消息 # running状态按job_id做debounce内容变化时仍然上报
if status == "running":
now = time.time()
cached = self._job_running_last_sent.get(item.job_id)
if cached is not None:
last_ts, last_data = cached
if now - last_ts < self._job_running_debounce_interval and last_data == feedback_data:
logger.trace(f"[WebSocketClient] Job status debounced (skip): {job_log} - {status}")
return
self._job_running_last_sent[item.job_id] = (now, feedback_data)
message = { message = {
"action": "job_status", "action": "job_status",
"data": { "data": {
@@ -1473,7 +1519,6 @@ 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)
logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}") logger.trace(f"[WebSocketClient] Job status published: {job_log} - {status}")
def send_ping(self, ping_id: str, timestamp: float) -> None: def send_ping(self, ping_id: str, timestamp: float) -> None:

View File

@@ -46,7 +46,7 @@ class WSConfig:
# HTTP配置 # HTTP配置
class HTTPConfig: class HTTPConfig:
remote_addr = "https://uni-lab.bohrium.com/api/v1" remote_addr = "https://leap-lab.bohrium.com/api/v1"
# ROS配置 # ROS配置

View File

@@ -1,9 +1,7 @@
""" """
LaiYu液体处理设备后端模块 LaiYu液体处理设备后端模块
提供设备后端接口和实现
""" """
from .laiyu_backend import LaiYuLiquidBackend, create_laiyu_backend from .laiyu_v_backend import UniLiquidHandlerLaiyuBackend
__all__ = ['LaiYuLiquidBackend', 'create_laiyu_backend'] __all__ = ['UniLiquidHandlerLaiyuBackend']

View File

@@ -1,334 +0,0 @@
"""
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)

View File

@@ -1,385 +1,307 @@
"""LaiYu PLR 后端 — 对齐路径 B 硬件交互模式
import json 硬件初始化顺序与 laiyu_liquid_station.py (路径 B) 一致:
1. XYZController(auto_connect=True) — 先开串口
2. PipetteController.connect_shared() — 共享 XYZ 的串口 / 锁
3. home_all_axes() + pipette.initialize()
"""
import logging
from typing import List, Optional, Union from typing import List, Optional, Union
from pylabrobot.liquid_handling.backends.backend import ( from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend
LiquidHandlerBackend,
)
from pylabrobot.liquid_handling.standard import ( from pylabrobot.liquid_handling.standard import (
Drop, Drop,
DropTipRack, DropTipRack,
MultiHeadAspirationContainer, MultiHeadAspirationContainer,
MultiHeadAspirationPlate, MultiHeadAspirationPlate,
MultiHeadDispenseContainer, MultiHeadDispenseContainer,
MultiHeadDispensePlate, MultiHeadDispensePlate,
Pickup, Pickup,
PickupTipRack, PickupTipRack,
ResourceDrop, ResourceDrop,
ResourceMove, ResourceMove,
ResourcePickup, ResourcePickup,
SingleChannelAspiration, SingleChannelAspiration,
SingleChannelDispense, SingleChannelDispense,
) )
from pylabrobot.resources import Resource, Tip from pylabrobot.resources import Resource, Tip
import rclpy from unilabos.devices.liquid_handling.laiyu.controllers.xyz_controller import XYZController
from rclpy.node import Node from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import (
from sensor_msgs.msg import JointState PipetteController,
import time TipStatus,
from rclpy.action import ActionClient )
from unilabos_msgs.action import SendCmd
import re
from unilabos.devices.ros_dev.liquid_handler_joint_publisher import JointStatePublisher logger = logging.getLogger(__name__)
from unilabos.devices.liquid_handling.laiyu.controllers.pipette_controller import PipetteController, TipStatus
class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend): class UniLiquidHandlerLaiyuBackend(LiquidHandlerBackend):
"""Chatter box backend for device-free testing. Prints out all operations.""" """LaiYu 硬件后端 — PLR Backend 接口实现"""
_pip_length = 5 def __init__(
_vol_length = 8 self,
_resource_length = 20 num_channels: int = 1,
_offset_length = 16 tip_length: float = 0,
_flow_rate_length = 10 total_height: float = 310,
_blowout_length = 10 port: str = "/dev/ttyUSB0",
_lld_z_length = 10 baudrate: int = 115200,
_kwargs_length = 15 pipette_address: int = 4,
_tip_type_length = 12 ):
_max_volume_length = 16 super().__init__()
_fitting_depth_length = 20 self._num_channels = num_channels
_tip_length_length = 16 self.tip_length = tip_length
# _pickup_method_length = 20 self.total_height = total_height
_filter_length = 10
def __init__(self, num_channels: int = 8 , tip_length: float = 0 , total_height: float = 310, port: str = "/dev/ttyUSB0"): # 保存配置,延迟到 setup() 再创建硬件对象
"""Initialize a chatter box backend.""" self._port = port
super().__init__() self._baudrate = baudrate
self._num_channels = num_channels self._pipette_address = pipette_address
self.tip_length = tip_length
self.total_height = total_height
# rclpy.init()
if not rclpy.ok():
rclpy.init()
self.joint_state_publisher = None
self.hardware_interface = PipetteController(port=port)
async def setup(self): self._xyz: Optional[XYZController] = None
# self.joint_state_publisher = JointStatePublisher() self._pipette_ctrl: Optional[PipetteController] = None
# self.hardware_interface.xyz_controller.connect_device() self._ros_node = None
# self.hardware_interface.xyz_controller.home_all_axes()
await super().setup()
self.hardware_interface.connect()
self.hardware_interface.initialize()
print("Setting up the liquid handler.") # ------------------------------------------------------------------ lifecycle
async def stop(self): def post_init(self, ros_node):
print("Stopping the liquid handler.") """接收 ROS 节点引用(由 Handler.post_init 调用)"""
self._ros_node = ros_node
def serialize(self) -> dict: async def setup(self):
return {**super().serialize(), "num_channels": self.num_channels} """按路径 B 顺序初始化硬件"""
await super().setup()
def pipette_aspirate(self, volume: float, flow_rate: float): # 1. XYZ 先开串口
self._xyz = XYZController(
port=self._port,
baudrate=self._baudrate,
auto_connect=True,
)
if not self._xyz.is_connected:
raise RuntimeError("XYZ 控制器连接失败")
self.hardware_interface.pipette.set_max_speed(flow_rate) # 2. PipetteController 共享 XYZ 串口
res = self.hardware_interface.pipette.aspirate(volume=volume) self._pipette_ctrl = PipetteController(
port=self._port,
address=self._pipette_address,
)
self._pipette_ctrl.connect_shared(
serial_conn=self._xyz.serial_conn,
serial_lock=self._xyz.serial_lock,
xyz_controller=self._xyz,
)
if not res: # 3. 回零 + 移液器初始化
self.hardware_interface.logger.error("吸取失败,当前体积: {self.hardware_interface.current_volume}") self._xyz.home_all_axes()
return self._pipette_ctrl.initialize()
self.hardware_interface.current_volume += volume logger.info("LaiYu 后端硬件初始化完成")
def pipette_dispense(self, volume: float, flow_rate: float): async def stop(self):
"""正确断开硬件"""
try:
if self._pipette_ctrl:
self._pipette_ctrl.disconnect_shared()
if self._xyz:
self._xyz.disconnect()
logger.info("LaiYu 后端硬件已断开")
except Exception as e:
logger.error(f"停止后端失败: {e}")
self.hardware_interface.pipette.set_max_speed(flow_rate) # ------------------------------------------------------------------ helpers
res = self.hardware_interface.pipette.dispense(volume=volume)
if not res:
self.hardware_interface.logger.error("排液失败,当前体积: {self.hardware_interface.current_volume}")
return
self.hardware_interface.current_volume -= volume
@property def _plr_to_machine_coords(self, resource, offset):
def num_channels(self) -> int: """PLR Resource 坐标 → 机器坐标 (倒置龙门架: total_height - z, -y)"""
return self._num_channels coordinate = resource.get_absolute_location(x="c", y="c")
x = coordinate.x + offset.x
y = coordinate.y + offset.y
z_plr = coordinate.z + offset.z
return x, -y, self.total_height - (z_plr + self.tip_length)
async def assigned_resource_callback(self, resource: Resource): def _pipette_aspirate(self, volume: float, flow_rate: float):
print(f"Resource {resource.name} was assigned to the liquid handler.") self._pipette_ctrl.pipette.set_max_speed(flow_rate)
res = self._pipette_ctrl.pipette.aspirate(volume=volume)
if not res:
logger.error(f"吸取失败,当前体积: {self._pipette_ctrl.current_volume}")
return
self._pipette_ctrl.current_volume += volume
async def unassigned_resource_callback(self, name: str): def _pipette_dispense(self, volume: float, flow_rate: float):
print(f"Resource {name} was unassigned from the liquid handler.") self._pipette_ctrl.pipette.set_max_speed(flow_rate)
res = self._pipette_ctrl.pipette.dispense(volume=volume)
if not res:
logger.error(f"排液失败,当前体积: {self._pipette_ctrl.current_volume}")
return
self._pipette_ctrl.current_volume -= volume
async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): # ------------------------------------------------------------------ properties
print("Picking up tips:")
# print(ops.tip)
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
for op, channel in zip(ops, use_channels): def serialize(self) -> dict:
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" return {**super().serialize(), "num_channels": self.num_channels}
row = (
f" p{channel}: "
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
# print(op.resource.get_absolute_location())
self.tip_length = ops[0].tip.total_tip_length @property
coordinate = ops[0].resource.get_absolute_location(x="c",y="c") def num_channels(self) -> int:
offset_xyz = ops[0].offset return self._num_channels
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED:
print("已有枪头,无需重复拾取")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height,speed=100)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "pick",channels=use_channels)
# goback()
# ------------------------------------------------------------------ resource callbacks
async def assigned_resource_callback(self, resource: Resource):
logger.info(f"Resource {resource.name} was assigned to the liquid handler.")
async def unassigned_resource_callback(self, name: str):
logger.info(f"Resource {name} was unassigned from the liquid handler.")
async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): # ------------------------------------------------------------------ pick_up_tips
print("Dropping tips:")
header = (
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'tip type':<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{'max volume (µL)':<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{'fitting depth (mm)':<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{'tip length (mm)':<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} "
f"{'filter':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(header)
for op, channel in zip(ops, use_channels): async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs):
offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" tip = ops[0].tip
row = ( self.tip_length = tip.total_tip_length
f" p{channel}: " x, y, z_top = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
f"{op.resource.name[-30:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{op.tip.__class__.__name__:<{UniLiquidHandlerLaiyuBackend._tip_type_length}} "
f"{op.tip.maximal_volume:<{UniLiquidHandlerLaiyuBackend._max_volume_length}} "
f"{op.tip.fitting_depth:<{UniLiquidHandlerLaiyuBackend._fitting_depth_length}} "
f"{op.tip.total_tip_length:<{UniLiquidHandlerLaiyuBackend._tip_length_length}} "
# f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} "
f"{'Yes' if op.tip.has_filter else 'No':<{UniLiquidHandlerLaiyuBackend._filter_length}}"
)
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c") self._pipette_ctrl._update_tip_status()
offset_xyz = ops[0].offset if self._pipette_ctrl.tip_status == TipStatus.TIP_ATTACHED:
x = coordinate.x + offset_xyz.x logger.warning("已有枪头,无需重复拾取")
y = coordinate.y + offset_xyz.y return
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z -20
# print(x, y, z)
# print("moving")
self.hardware_interface._update_tip_status()
if self.hardware_interface.tip_status == TipStatus.NO_TIP:
print("无枪头,无需丢弃")
return
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200)
self.hardware_interface.eject_tip
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height)
async def aspirate( try:
self, # 1. 移到枪头正上方
ops: List[SingleChannelAspiration], self._xyz.move_to_work_coord_safe(x=x, y=y, z=z_top, speed=200)
use_channels: List[int], # 2. 下压到套枪头深度fitting_depth 是枪头套入长度)
**backend_kwargs, z_pickup = z_top + tip.fitting_depth
): self._xyz.move_to_work_coord_safe(z=z_pickup, speed=100)
print("Aspirating:") # 3. 退回安全高度
header = ( self._xyz.move_to_work_coord_safe(
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} " z=self._xyz.machine_config.safe_z_height, speed=100
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} " )
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} " except Exception as e:
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} " logger.error(f"pick_up_tips 移动失败: {e}")
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} " raise
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
for o, p in zip(ops, use_channels): # ------------------------------------------------------------------ drop_tips
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}"
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._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}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
# 判断枪头是否存在 async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs):
self.hardware_interface._update_tip_status() x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: z -= 20 # 额外下移补偿
print("无枪头,无法吸液")
return
# 判断吸液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume + ops[0].volume + blow_out_air_volume > self.hardware_interface.max_volume:
self.hardware_interface.logger.error(f"吸液量超过枪头容量: {self.hardware_interface.current_volume + ops[0].volume} > {self.hardware_interface.max_volume}")
return
# 移动到吸液位置 self._pipette_ctrl._update_tip_status()
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) if self._pipette_ctrl.tip_status == TipStatus.NO_TIP:
self.pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate) logger.warning("无枪头,无需丢弃")
return
try:
self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
self._pipette_ctrl.eject_tip() # 修复: 原来缺少 ()
self._xyz.move_to_work_coord_safe(
z=self._xyz.machine_config.safe_z_height
)
except Exception as e:
logger.error(f"drop_tips 失败: {e}")
raise
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) # ------------------------------------------------------------------ aspirate
if blow_out_air_volume >0:
self.pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
async def aspirate(
self,
ops: List[SingleChannelAspiration],
use_channels: List[int],
**backend_kwargs,
):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
raise RuntimeError("无枪头,无法吸液")
flow_rate = backend_kwargs.get("flow_rate", 500)
blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
async def dispense( if (
self, self._pipette_ctrl.current_volume + ops[0].volume + blow_out_air_volume
ops: List[SingleChannelDispense], > self._pipette_ctrl.max_volume
use_channels: List[int], ):
**backend_kwargs, raise RuntimeError(
): f"吸液量超过枪头容量: "
# print("Dispensing:") f"{self._pipette_ctrl.current_volume + ops[0].volume} > {self._pipette_ctrl.max_volume}"
header = ( )
f"{'pip#':<{UniLiquidHandlerLaiyuBackend._pip_length}} "
f"{'vol(ul)':<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{'resource':<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{'offset':<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{'flow rate':<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{'blowout':<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{'lld_z':<{UniLiquidHandlerLaiyuBackend._lld_z_length}} "
# f"{'liquids':<20}" # TODO: add liquids
)
for key in backend_kwargs:
header += f"{key:<{UniLiquidHandlerLaiyuBackend._kwargs_length}} "[-16:]
# print(header)
for o, p in zip(ops, use_channels): self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" self._pipette_aspirate(volume=ops[0].volume, flow_rate=flow_rate)
row = (
f" p{p}: "
f"{o.volume:<{UniLiquidHandlerLaiyuBackend._vol_length}} "
f"{o.resource.name[-20:]:<{UniLiquidHandlerLaiyuBackend._resource_length}} "
f"{offset:<{UniLiquidHandlerLaiyuBackend._offset_length}} "
f"{str(o.flow_rate):<{UniLiquidHandlerLaiyuBackend._flow_rate_length}} "
f"{str(o.blow_out_air_volume):<{UniLiquidHandlerLaiyuBackend._blowout_length}} "
f"{str(o.liquid_height):<{UniLiquidHandlerLaiyuBackend._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:<{UniLiquidHandlerLaiyuBackend._kwargs_length}}"
# print(row)
coordinate = ops[0].resource.get_absolute_location(x="c",y="c")
offset_xyz = ops[0].offset
x = coordinate.x + offset_xyz.x
y = coordinate.y + offset_xyz.y
z = self.total_height - (coordinate.z + self.tip_length) + offset_xyz.z
# print(x, y, z)
# print("moving")
# 判断枪头是否存在 self._xyz.move_to_work_coord_safe(
self.hardware_interface._update_tip_status() z=self._xyz.machine_config.safe_z_height
if not self.hardware_interface.tip_status == TipStatus.TIP_ATTACHED: )
print("无枪头,无法排液") if blow_out_air_volume > 0:
return self._pipette_aspirate(volume=blow_out_air_volume, flow_rate=flow_rate)
# 判断排液量是否超过枪头容量
flow_rate = backend_kwargs["flow_rate"] if "flow_rate" in backend_kwargs else 500
blow_out_air_volume = backend_kwargs["blow_out_air_volume"] if "blow_out_air_volume" in backend_kwargs else 0
if self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume < 0:
self.hardware_interface.logger.error(f"排液量超过枪头容量: {self.hardware_interface.current_volume - ops[0].volume - blow_out_air_volume} < 0")
return
# ------------------------------------------------------------------ dispense
# 移动到排液位置 async def dispense(
self.hardware_interface.xyz_controller.move_to_work_coord_safe(x=x, y=-y, z=z,speed=200) self,
self.pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate) ops: List[SingleChannelDispense],
use_channels: List[int],
**backend_kwargs,
):
x, y, z = self._plr_to_machine_coords(ops[0].resource, ops[0].offset)
self._pipette_ctrl._update_tip_status()
if self._pipette_ctrl.tip_status != TipStatus.TIP_ATTACHED:
raise RuntimeError("无枪头,无法排液")
self.hardware_interface.xyz_controller.move_to_work_coord_safe(z=self.hardware_interface.xyz_controller.machine_config.safe_z_height) flow_rate = backend_kwargs.get("flow_rate", 500)
if blow_out_air_volume > 0: blow_out_air_volume = backend_kwargs.get("blow_out_air_volume", 0)
self.pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
# self.joint_state_publisher.send_resource_action(ops[0].resource.name, x, y, z, "",channels=use_channels)
async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): if (
print(f"Picking up tips from {pickup.resource.name}.") self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume < 0
):
raise RuntimeError(
f"排液量超过当前体积: "
f"{self._pipette_ctrl.current_volume - ops[0].volume - blow_out_air_volume} < 0"
)
async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): self._xyz.move_to_work_coord_safe(x=x, y=y, z=z, speed=200)
print(f"Dropping tips to {drop.resource.name}.") self._pipette_dispense(volume=ops[0].volume, flow_rate=flow_rate)
async def aspirate96( self._xyz.move_to_work_coord_safe(
self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] z=self._xyz.machine_config.safe_z_height
): )
if isinstance(aspiration, MultiHeadAspirationPlate): if blow_out_air_volume > 0:
resource = aspiration.wells[0].parent self._pipette_dispense(volume=blow_out_air_volume, flow_rate=flow_rate)
else:
resource = aspiration.container
print(f"Aspirating {aspiration.volume} from {resource}.")
async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): # ------------------------------------------------------------------ 96-channel stubs
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
print(f"Dispensing {dispense.volume} to {resource}.")
async def pick_up_resource(self, pickup: ResourcePickup): async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs):
print(f"Picking up resource: {pickup}") logger.info(f"Picking up tips from {pickup.resource.name}.")
async def move_picked_up_resource(self, move: ResourceMove): async def drop_tips96(self, drop: DropTipRack, **backend_kwargs):
print(f"Moving picked up resource: {move}") logger.info(f"Dropping tips to {drop.resource.name}.")
async def drop_resource(self, drop: ResourceDrop): async def aspirate96(
print(f"Dropping resource: {drop}") self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer]
):
if isinstance(aspiration, MultiHeadAspirationPlate):
resource = aspiration.wells[0].parent
else:
resource = aspiration.container
logger.info(f"Aspirating {aspiration.volume} from {resource}.")
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: async def dispense96(
return True self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]
):
if isinstance(dispense, MultiHeadDispensePlate):
resource = dispense.wells[0].parent
else:
resource = dispense.container
logger.info(f"Dispensing {dispense.volume} to {resource}.")
async def pick_up_resource(self, pickup: ResourcePickup):
logger.info(f"Picking up resource: {pickup}")
async def move_picked_up_resource(self, move: ResourceMove):
logger.info(f"Moving picked up resource: {move}")
async def drop_resource(self, drop: ResourceDrop):
logger.info(f"Dropping resource: {drop}")
def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool:
return True

View File

@@ -5,21 +5,16 @@
封装SOPA移液器的高级控制功能 封装SOPA移液器的高级控制功能
""" """
# 添加项目根目录到Python路径以解决模块导入问题
import sys import sys
import os import os
from tkinter import N
_current_file = os.path.abspath(__file__)
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_current_file)))))
if _project_root not in sys.path:
sys.path.insert(0, _project_root)
from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException from unilabos.devices.liquid_handling.laiyu.drivers.xyz_stepper_driver import ModbusException
# 无论如何都添加项目根目录到路径
current_file = os.path.abspath(__file__)
# 从 .../Uni-Lab-OS/unilabos/devices/LaiYu_Liquid/controllers/pipette_controller.py
# 向上5级到 .../Uni-Lab-OS
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_file)))))
# 强制添加项目根目录到sys.path的开头
sys.path.insert(0, project_root)
import time import time
import logging import logging
from typing import Optional, List, Dict, Tuple from typing import Optional, List, Dict, Tuple
@@ -172,24 +167,62 @@ class PipetteController:
try: try:
self.xyz_controller = XYZController(self.xyz_port, auto_connect=False) self.xyz_controller = XYZController(self.xyz_port, auto_connect=False)
self.xyz_controller.serial_conn = self.pipette.serial_port self.xyz_controller.serial_conn = self.pipette.serial_port
self.xyz_controller.serial_lock = self.pipette.lock
self.xyz_controller.is_connected = True self.xyz_controller.is_connected = True
logger.info("XYZ控制器与移液器共享串口和互斥锁")
except Exception as e: except Exception as e:
logger.info("未配置XYZ步进电机端口跳过运动控制器连接") logger.warning(f"共享端口 XYZ 控制器创建失败: {e}")
self.xyz_controller = None
self.xyz_connected = False
return True return True
except Exception as e: except Exception as e:
logger.error(f"设备连接失败: {e}") logger.error(f"设备连接失败: {e}")
return False return False
def connect_shared(self, serial_conn, serial_lock, xyz_controller: XYZController) -> bool:
"""使用已连接的串口和XYZ控制器路径 B 模式XYZ 先开串口,移液器共享)
Args:
serial_conn: 已打开的串口连接(来自 XYZController
serial_lock: 串口互斥锁(来自 XYZController
xyz_controller: 已连接的 XYZController 实例
"""
try:
self.pipette.serial_port = serial_conn
self.pipette.lock = serial_lock
self.pipette.is_connected = True
self.xyz_controller = xyz_controller
self.xyz_connected = True
logger.info("移液控制器已通过 connect_shared 共享 XYZ 串口")
return True
except Exception as e:
logger.error(f"connect_shared 失败: {e}")
return False
def disconnect_shared(self) -> None:
"""释放共享串口引用(与 connect_shared 对称)。
注意:不关闭串口本身,串口由 XYZController 负责关闭。
"""
try:
self.pipette.serial_port = None
self.pipette.lock = None
self.pipette.is_connected = False
self.xyz_controller = None
self.xyz_connected = False
logger.info("移液控制器已释放共享串口引用")
except Exception as e:
logger.error(f"disconnect_shared 失败: {e}")
def initialize(self) -> bool: def initialize(self) -> bool:
"""初始化移液器""" """初始化移液器"""
try: try:
if self.pipette.initialize(): if self.pipette.initialize():
logger.info("移液器初始化成功") logger.info("移液器初始化成功")
# 检查枪头状态
self._update_tip_status() self._update_tip_status()
self.xyz_controller.home_all_axes()
self.xyz_controller.move_to_work_coord_safe(x=0, y=-150, z=0)
return True return True
return False return False
except Exception as e: except Exception as e:
@@ -198,19 +231,21 @@ class PipetteController:
def disconnect(self): def disconnect(self):
"""断开连接""" """断开连接"""
# 断开移液器连接 if self.xyz_controller and self.xyz_connected:
if self.xyz_port != self.pipette_port:
try:
self.xyz_controller.disconnect()
logger.info("XYZ 步进电机已断开")
except Exception as e:
logger.error(f"断开 XYZ 步进电机失败: {e}")
else:
self.xyz_controller.serial_conn = None
self.xyz_connected = False
self.xyz_controller = None
self.pipette.disconnect() self.pipette.disconnect()
logger.info("移液器已断开") logger.info("移液器已断开")
# 断开 XYZ 步进电机连接
if self.xyz_controller and self.xyz_connected:
try:
self.xyz_controller.disconnect()
self.xyz_connected = False
logger.info("XYZ 步进电机已断开")
except Exception as e:
logger.error(f"断开 XYZ 步进电机失败: {e}")
def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool: def _check_xyz_safety(self, axis: MotorAxis, target_position: int) -> bool:
""" """
检查 XYZ 轴移动的安全性 检查 XYZ 轴移动的安全性
@@ -343,10 +378,9 @@ class PipetteController:
""" """
success = True success = True
# 停止移液器操作
try: try:
if self.pipette and self.connected: if self.pipette and self.pipette.is_connected:
# 这里可以添加移液器的紧急停止逻辑 self.pipette.emergency_stop()
logger.info("移液器紧急停止") logger.info("移液器紧急停止")
except Exception as e: except Exception as e:
logger.error(f"移液器紧急停止失败: {e}") logger.error(f"移液器紧急停止失败: {e}")

View File

@@ -13,7 +13,7 @@ from pylabrobot.liquid_handling import (
SingleChannelDispense, SingleChannelDispense,
PickupTipRack, PickupTipRack,
DropTipRack, DropTipRack,
MultiHeadAspirationPlate, ChatterBoxBackend, LiquidHandlerChatterboxBackend, MultiHeadAspirationPlate,
) )
from pylabrobot.liquid_handling.standard import ( from pylabrobot.liquid_handling.standard import (
MultiHeadAspirationContainer, MultiHeadAspirationContainer,
@@ -41,12 +41,6 @@ class TransformXYZDeck(Deck):
super().__init__(name, size_x, size_y, size_z) super().__init__(name, size_x, size_y, size_z)
self.name = name self.name = name
class TransformXYZBackend(LiquidHandlerBackend):
def __init__(self, name: str, host: str, port: int, timeout: float):
super().__init__()
self.host = host
self.port = port
self.timeout = timeout
class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend): class TransformXYZRvizBackend(UniLiquidHandlerRvizBackend):
def __init__(self, name: str, channel_num: int): def __init__(self, name: str, channel_num: int):
@@ -86,7 +80,9 @@ class TransformXYZContainer(Plate, TipRack):
class TransformXYZHandler(LiquidHandlerAbstract): class TransformXYZHandler(LiquidHandlerAbstract):
support_touch_tip = False support_touch_tip = False
def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True, **backend_kwargs): def __init__(self, deck: Deck, host: str = "127.0.0.1", port: int = 9999, timeout: float = 10.0, channel_num=1, simulator=True,
serial_port: str = "/dev/ttyUSB0", baudrate: int = 115200, pipette_address: int = 4,
total_height: float = 310, **backend_kwargs):
# Handle case where deck is passed as a dict (from serialization) # Handle case where deck is passed as a dict (from serialization)
if isinstance(deck, dict): if isinstance(deck, dict):
# Try to create a TransformXYZDeck from the dict # Try to create a TransformXYZDeck from the dict
@@ -102,11 +98,22 @@ class TransformXYZHandler(LiquidHandlerAbstract):
deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100) deck = TransformXYZDeck(name='deck', size_x=100, size_y=100, size_z=100)
if simulator: if simulator:
self._unilabos_backend = TransformXYZRvizBackend(name="laiyu",channel_num=channel_num) self._unilabos_backend = TransformXYZRvizBackend(name="laiyu", channel_num=channel_num)
else: else:
self._unilabos_backend = TransformXYZBackend(name="laiyu",host=host, port=port, timeout=timeout) self._unilabos_backend = UniLiquidHandlerLaiyuBackend(
num_channels=channel_num,
total_height=total_height,
port=serial_port,
baudrate=baudrate,
pipette_address=pipette_address,
)
super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num) super().__init__(backend=self._unilabos_backend, deck=deck, simulator=simulator, channel_num=channel_num)
def post_init(self, ros_node):
super().post_init(ros_node)
if hasattr(self._unilabos_backend, 'post_init'):
self._unilabos_backend.post_init(ros_node)
async def add_liquid( async def add_liquid(
self, self,
asp_vols: Union[List[float], float], asp_vols: Union[List[float], float],
@@ -128,7 +135,25 @@ class TransformXYZHandler(LiquidHandlerAbstract):
mix_liquid_height: Optional[float] = None, mix_liquid_height: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
): ):
pass return await super().add_liquid(
asp_vols=asp_vols,
dis_vols=dis_vols,
reagent_sources=reagent_sources,
targets=targets,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
delays=delays,
mix_time=mix_time,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
none_keys=none_keys,
)
async def aspirate( async def aspirate(
self, self,
@@ -142,7 +167,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs, **backend_kwargs,
): ):
pass return await super().aspirate(
resources=resources,
vols=vols,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
**backend_kwargs,
)
async def dispense( async def dispense(
self, self,
@@ -156,7 +191,17 @@ class TransformXYZHandler(LiquidHandlerAbstract):
spread: Literal["wide", "tight", "custom"] = "wide", spread: Literal["wide", "tight", "custom"] = "wide",
**backend_kwargs, **backend_kwargs,
): ):
pass return await super().dispense(
resources=resources,
vols=vols,
use_channels=use_channels,
flow_rates=flow_rates,
offsets=offsets,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
**backend_kwargs,
)
async def drop_tips( async def drop_tips(
self, self,
@@ -166,7 +211,13 @@ class TransformXYZHandler(LiquidHandlerAbstract):
allow_nonzero_volume: bool = False, allow_nonzero_volume: bool = False,
**backend_kwargs, **backend_kwargs,
): ):
pass return await super().drop_tips(
tip_spots=tip_spots,
use_channels=use_channels,
offsets=offsets,
allow_nonzero_volume=allow_nonzero_volume,
**backend_kwargs,
)
async def mix( async def mix(
self, self,
@@ -178,7 +229,15 @@ class TransformXYZHandler(LiquidHandlerAbstract):
mix_rate: Optional[float] = None, mix_rate: Optional[float] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
): ):
pass return await super().mix(
targets=targets,
mix_time=mix_time,
mix_vol=mix_vol,
height_to_bottom=height_to_bottom,
offsets=offsets,
mix_rate=mix_rate,
none_keys=none_keys,
)
async def pick_up_tips( async def pick_up_tips(
self, self,
@@ -187,7 +246,12 @@ class TransformXYZHandler(LiquidHandlerAbstract):
offsets: Optional[List[Coordinate]] = None, offsets: Optional[List[Coordinate]] = None,
**backend_kwargs, **backend_kwargs,
): ):
pass return await super().pick_up_tips(
tip_spots=tip_spots,
use_channels=use_channels,
offsets=offsets,
**backend_kwargs,
)
async def transfer_liquid( async def transfer_liquid(
self, self,
@@ -214,5 +278,26 @@ class TransformXYZHandler(LiquidHandlerAbstract):
delays: Optional[List[int]] = None, delays: Optional[List[int]] = None,
none_keys: List[str] = [], none_keys: List[str] = [],
): ):
pass return await super().transfer_liquid(
sources=sources,
targets=targets,
tip_racks=tip_racks,
use_channels=use_channels,
asp_vols=asp_vols,
dis_vols=dis_vols,
asp_flow_rates=asp_flow_rates,
dis_flow_rates=dis_flow_rates,
offsets=offsets,
touch_tip=touch_tip,
liquid_height=liquid_height,
blow_out_air_volume=blow_out_air_volume,
spread=spread,
is_96_well=is_96_well,
mix_stage=mix_stage,
mix_times=mix_times,
mix_vol=mix_vol,
mix_rate=mix_rate,
mix_liquid_height=mix_liquid_height,
delays=delays,
none_keys=none_keys,
)

View File

@@ -57,6 +57,18 @@ class TransferLiquidReturn(TypedDict):
targets: List[List[ResourceDict]] targets: List[List[ResourceDict]]
class SetLiquidReturn(TypedDict):
wells: list
volumes: list
class SetLiquidFromPlateReturn(TypedDict):
plate: list
wells: list
volumes: list
class LiquidHandlerMiddleware(LiquidHandler): class LiquidHandlerMiddleware(LiquidHandler):
def __init__( def __init__(
self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs self, backend: LiquidHandlerBackend, deck: Deck, simulator: bool = False, channel_num: int = 8, **kwargs

View File

@@ -0,0 +1,376 @@
# -*- coding: utf-8 -*-
"""
ZDT X42 Closed-Loop Stepper Motor Driver
RS485 Serial Communication via USB-Serial Converter
- Baudrate: 115200
"""
import serial
import time
import threading
import struct
import logging
from typing import Optional, Any
try:
from unilabos.device_comms.universal_driver import UniversalDriver
except ImportError:
class UniversalDriver:
def __init__(self, *args, **kwargs):
self.logger = logging.getLogger(self.__class__.__name__)
def execute_command_from_outer(self, command: Any): pass
from serial.rs485 import RS485Settings
class ZDTX42Driver(UniversalDriver):
"""
ZDT X42 闭环步进电机驱动器
支持功能:
- 速度模式运行
- 位置模式运行 (相对/绝对)
- 位置读取和清零
- 使能/禁用控制
通信协议:
- 帧格式: [设备ID] [功能码] [数据...] [校验位=0x6B]
- 响应长度根据功能码决定
"""
def __init__(
self,
port: str,
baudrate: int = 115200,
device_id: int = 1,
timeout: float = 0.5,
debug: bool = False
):
"""
初始化 ZDT X42 电机驱动
Args:
port: 串口设备路径
baudrate: 波特率 (默认 115200)
device_id: 设备地址 (1-255)
timeout: 通信超时时间(秒)
debug: 是否启用调试输出
"""
super().__init__()
self.id = device_id
self.debug = debug
self.lock = threading.RLock()
self.status = "idle" # 对应注册表中的 status (str)
self.position = 0 # 对应注册表中的 position (int)
try:
self.ser = serial.Serial(
port=port,
baudrate=baudrate,
timeout=timeout,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE
)
# 启用 RS485 模式
try:
self.ser.rs485_mode = RS485Settings(
rts_level_for_tx=True,
rts_level_for_rx=False
)
except Exception:
pass # RS485 模式是可选的
self.logger.info(
f"ZDT X42 Motor connected: {port} "
f"(Baud: {baudrate}, ID: {device_id})"
)
# 自动使能电机,确保初始状态可运动
self.enable(True)
# 启动背景轮询线程,确保 position 实时刷新
self._stop_event = threading.Event()
self._polling_thread = threading.Thread(
target=self._update_loop,
name=f"ZDTPolling_{port}",
daemon=True
)
self._polling_thread.start()
except Exception as e:
self.logger.error(f"Failed to open serial port {port}: {e}")
self.ser = None
def _update_loop(self):
"""背景循环读取电机位置"""
while not self._stop_event.is_set():
try:
self.get_position()
except Exception as e:
if self.debug:
self.logger.error(f"Polling error: {e}")
time.sleep(1.0) # 每1秒刷新一次位置数据
def _send(self, func_code: int, payload: list) -> bytes:
"""
发送指令并接收响应
Args:
func_code: 功能码
payload: 数据负载 (list of bytes)
Returns:
响应数据 (bytes)
"""
if not self.ser:
self.logger.error("Serial port not available")
return b""
with self.lock:
# 清空输入缓冲区
self.ser.reset_input_buffer()
# 构建消息: [ID] [功能码] [数据...] [校验位=0x6B]
message = bytes([self.id, func_code] + payload + [0x6B])
# 发送
self.ser.write(message)
# 根据功能码决定响应长度
# 查询类指令返回 10 字节,控制类指令返回 4 字节
read_len = 10 if func_code in [0x31, 0x32, 0x35, 0x24, 0x27] else 4
response = self.ser.read(read_len)
# 调试输出
if self.debug:
sent_hex = message.hex().upper()
recv_hex = response.hex().upper() if response else 'TIMEOUT'
print(f"[ID {self.id}] TX: {sent_hex} → RX: {recv_hex}")
return response
def enable(self, on: bool = True) -> bool:
"""
使能/禁用电机
Args:
on: True=使能(锁轴), False=禁用(松轴)
Returns:
是否成功
"""
state = 1 if on else 0
resp = self._send(0xF3, [0xAB, state, 0])
return len(resp) >= 4
def move_speed(
self,
speed_rpm: int,
direction: str = "CW",
acceleration: int = 10
) -> bool:
"""
速度模式运行
Args:
speed_rpm: 转速 (RPM)
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
acceleration: 加速度 (0-255)
Returns:
是否成功
"""
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
speed_bytes = struct.pack('>H', int(speed_rpm))
self.status = f"moving@{speed_rpm}rpm"
resp = self._send(0xF6, [dir_val, speed_bytes[0], speed_bytes[1], acceleration, 0])
return len(resp) >= 4
def move_position(
self,
pulses: int,
speed_rpm: int,
direction: str = "CW",
acceleration: int = 10,
absolute: bool = False
) -> bool:
"""
位置模式运行
Args:
pulses: 脉冲数
speed_rpm: 转速 (RPM)
direction: 方向 ("CW"=顺时针, "CCW"=逆时针)
acceleration: 加速度 (0-255)
absolute: True=绝对位置, False=相对位置
Returns:
是否成功
"""
dir_val = 0 if direction.upper() in ["CW", "顺时针"] else 1
speed_bytes = struct.pack('>H', int(speed_rpm))
self.status = f"moving_to_{pulses}"
pulse_bytes = struct.pack('>I', int(pulses))
abs_flag = 1 if absolute else 0
payload = [
dir_val,
speed_bytes[0], speed_bytes[1],
acceleration,
pulse_bytes[0], pulse_bytes[1], pulse_bytes[2], pulse_bytes[3],
abs_flag,
0
]
resp = self._send(0xFD, payload)
return len(resp) >= 4
def stop(self) -> bool:
"""
停止电机
Returns:
是否成功
"""
self.status = "idle"
resp = self._send(0xFE, [0x98, 0])
return len(resp) >= 4
def rotate_quarter(self, speed_rpm: int = 60, direction: str = "CW") -> bool:
"""
电机旋转 1/4 圈 (阻塞式)
假设电机细分为 3200 脉冲/圈1/4 圈 = 800 脉冲
"""
pulses = 800
success = self.move_position(pulses=pulses, speed_rpm=speed_rpm, direction=direction, absolute=False)
if success:
# 计算预估旋转时间并进行阻塞等待 (Time = revolutions / (RPM/60))
# 1/4 rev / (RPM/60) = 15.0 / RPM
estimated_time = 15.0 / max(1, speed_rpm)
time.sleep(estimated_time + 0.5) # 额外给 0.5 秒缓冲
self.status = "idle"
return success
def wait_time(self, duration_s: float) -> bool:
"""
等待指定时间 (秒)
"""
self.logger.info(f"Waiting for {duration_s} seconds...")
time.sleep(duration_s)
return True
def set_zero(self) -> bool:
"""
清零当前位置
Returns:
是否成功
"""
resp = self._send(0x0A, [])
return len(resp) >= 4
def get_position(self) -> Optional[int]:
"""
读取当前位置 (脉冲数)
Returns:
当前位置脉冲数,失败返回 None
"""
resp = self._send(0x32, [])
if len(resp) >= 8:
# 响应格式: [ID] [Func] [符号位] [数值4字节] [校验]
sign = resp[2] # 0=正, 1=负
value = struct.unpack('>I', resp[3:7])[0]
self.position = -value if sign == 1 else value
if self.debug:
print(f"[Position] Raw: {resp.hex().upper()}, Parsed: {self.position}")
return self.position
self.logger.warning("Failed to read position")
return None
def close(self):
"""关闭串口连接并停止线程"""
if hasattr(self, '_stop_event'):
self._stop_event.set()
if self.ser and self.ser.is_open:
self.ser.close()
self.logger.info("Serial port closed")
# ============================================================
# 测试和调试代码
# ============================================================
def test_motor():
"""基础功能测试"""
logging.basicConfig(level=logging.INFO)
print("="*60)
print("ZDT X42 电机驱动测试")
print("="*60)
driver = ZDTX42Driver(
port="/dev/tty.usbserial-3110",
baudrate=115200,
device_id=2,
debug=True
)
if not driver.ser:
print("❌ 串口打开失败")
return
try:
# 测试 1: 读取位置
print("\n[1] 读取当前位置")
pos = driver.get_position()
print(f"✓ 当前位置: {pos} 脉冲")
# 测试 2: 使能
print("\n[2] 使能电机")
driver.enable(True)
time.sleep(0.3)
print("✓ 电机已锁定")
# 测试 3: 相对位置运动
print("\n[3] 相对位置运动 (1000脉冲)")
driver.move_position(pulses=1000, speed_rpm=60, direction="CW")
time.sleep(2)
pos = driver.get_position()
print(f"✓ 新位置: {pos}")
# 测试 4: 速度运动
print("\n[4] 速度模式 (30RPM, 3秒)")
driver.move_speed(speed_rpm=30, direction="CW")
time.sleep(3)
driver.stop()
pos = driver.get_position()
print(f"✓ 停止后位置: {pos}")
# 测试 5: 禁用
print("\n[5] 禁用电机")
driver.enable(False)
print("✓ 电机已松开")
print("\n" + "="*60)
print("✅ 测试完成")
print("="*60)
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
finally:
driver.close()
if __name__ == "__main__":
test_motor()

View File

@@ -623,6 +623,119 @@ class ChinweDevice(UniversalDriver):
time.sleep(duration) time.sleep(duration)
return True return True
def separation_step(self, motor_id: int = 5, speed: int = 60, pulses: int = 700,
max_cycles: int = 0, timeout: int = 300) -> bool:
"""
分液步骤 - 液位传感器与电机联动
当液位传感器检测到"有液"时,电机顺时针旋转指定脉冲数
当液位传感器检测到"无液"时,电机逆时针旋转指定脉冲数
:param motor_id: 电机ID (必须在初始化时配置的motor_ids中)
:param speed: 电机转速 (RPM)
:param pulses: 每次旋转的脉冲数 (默认700约为1/4圈,假设3200脉冲/圈)
:param max_cycles: 最大执行循环次数 (0=无限制,默认0)
:param timeout: 整体超时时间 (秒)
:return: 成功返回True,超时或失败返回False
"""
motor_id = int(motor_id)
speed = int(speed)
pulses = int(pulses)
max_cycles = int(max_cycles)
timeout = int(timeout)
# 检查电机是否存在
if motor_id not in self.motors:
self.logger.error(f"Motor {motor_id} not found in configured motors: {list(self.motors.keys())}")
return False
# 检查传感器是否可用
if not self.sensor:
self.logger.error("Sensor not initialized")
return False
motor = self.motors[motor_id]
# 停止轮询线程,避免与 separation_step 同时读取传感器造成串口冲突
self.logger.info("Stopping polling thread for separation_step...")
self._stop_event.set()
if self._poll_thread and self._poll_thread.is_alive():
self._poll_thread.join(timeout=2.0)
# 使能电机
self.logger.info(f"Enabling motor {motor_id}...")
motor.enable(True)
time.sleep(0.2)
self.logger.info(f"Starting separation step: motor_id={motor_id}, speed={speed} RPM, "
f"pulses={pulses}, max_cycles={max_cycles}, timeout={timeout}s")
# 记录上一次的液位状态
last_level = None
cycle_count = 0
start_time = time.time()
error_count = 0
try:
while True:
# 检查超时
if time.time() - start_time > timeout:
self.logger.warning(f"Separation step timeout after {timeout} seconds")
return False
# 检查循环次数限制
if max_cycles > 0 and cycle_count >= max_cycles:
self.logger.info(f"Separation step completed: reached max_cycles={max_cycles}")
return True
# 读取传感器数据
data = self.sensor.read_level()
if data is None:
error_count += 1
if error_count > 5:
self.logger.warning("Sensor read failed multiple times, retrying...")
error_count = 0
time.sleep(0.5)
continue
error_count = 0
current_level = data['level']
rssi = data['rssi']
# 检测状态变化 (包括首次检测)
if current_level != last_level:
cycle_count += 1
if current_level:
# 有液 -> 电机顺时针旋转
self.logger.info(f"[Cycle {cycle_count}] Liquid detected (RSSI={rssi}), "
f"rotating motor {motor_id} clockwise {pulses} pulses")
motor.run_position(pulses=pulses, speed_rpm=speed, direction=0, absolute=False)
# 等待电机完成 (预估时间)
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
else:
# 无液 -> 电机逆时针旋转
self.logger.info(f"[Cycle {cycle_count}] No liquid detected (RSSI={rssi}), "
f"rotating motor {motor_id} counter-clockwise {pulses} pulses")
motor.run_position(pulses=pulses, speed_rpm=speed, direction=1, absolute=False)
# 等待电机完成 (预估时间)
estimated_time = 15.0 / max(1, speed)
time.sleep(estimated_time + 0.5)
# 更新状态
last_level = current_level
# 轮询间隔
time.sleep(0.1)
finally:
# 恢复轮询线程
self.logger.info("Restarting polling thread...")
self._start_polling()
def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool: def execute_command_from_outer(self, command_dict: Dict[str, Any]) -> bool:
"""支持标准 JSON 指令调用""" """支持标准 JSON 指令调用"""
return super().execute_command_from_outer(command_dict) return super().execute_command_from_outer(command_dict)

View File

@@ -0,0 +1,379 @@
# -*- coding: utf-8 -*-
"""
XKC RS485 液位传感器 (Modbus RTU)
说明:
1. 遵循 Modbus-RTU 协议。
2. 数据寄存器: 0x0001 (液位状态, 1=有液, 0=无液), 0x0002 (RSSI 信号强度)。
3. 地址寄存器: 0x0004 (可读写, 范围 1-254)。
4. 波特率寄存器: 0x0005 (可写, 代码表见 change_baudrate 方法)。
"""
import struct
import threading
import time
import logging
import serial
from typing import Optional, Dict, Any, List
from unilabos.device_comms.universal_driver import UniversalDriver
class TransportManager:
"""
统一通信管理类。
仅支持 串口 (Serial/有线) 连接。
"""
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 3.0, logger=None):
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.logger = logger
self.lock = threading.RLock() # 线程锁,确保多设备共用一个连接时不冲突
self.serial = None
self._connect_serial()
def _connect_serial(self):
try:
self.serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout
)
except Exception as e:
raise ConnectionError(f"Serial open failed: {e}")
def close(self):
"""关闭连接"""
if self.serial and self.serial.is_open:
self.serial.close()
def clear_buffer(self):
"""清空缓冲区 (Thread-safe)"""
with self.lock:
if self.serial:
self.serial.reset_input_buffer()
def write(self, data: bytes):
"""发送原始字节"""
with self.lock:
if self.serial:
self.serial.write(data)
def read(self, size: int) -> bytes:
"""读取指定长度字节"""
if self.serial:
return self.serial.read(size)
return b''
class XKCSensorDriver(UniversalDriver):
"""XKC RS485 液位传感器 (Modbus RTU)"""
def __init__(self, port: str, baudrate: int = 9600, device_id: int = 6,
threshold: int = 300, timeout: float = 3.0, debug: bool = False):
super().__init__()
self.port = port
self.baudrate = baudrate
self.device_id = device_id
self.threshold = threshold
self.timeout = timeout
self.debug = debug
self.level = False
self.rssi = 0
self.status = {"level": self.level, "rssi": self.rssi}
try:
self.transport = TransportManager(port, baudrate, timeout, logger=self.logger)
self.logger.info(f"XKCSensorDriver connected to {port} (ID: {device_id})")
except Exception as e:
self.logger.error(f"Failed to connect XKCSensorDriver: {e}")
self.transport = None
# 启动背景轮询线程,确保 status 实时刷新
self._stop_event = threading.Event()
self._polling_thread = threading.Thread(
target=self._update_loop,
name=f"XKCPolling_{port}",
daemon=True
)
if self.transport:
self._polling_thread.start()
def _update_loop(self):
"""背景循环读取传感器数据"""
while not self._stop_event.is_set():
try:
self.read_level()
except Exception as e:
if self.debug:
self.logger.error(f"Polling error: {e}")
time.sleep(2.0) # 每2秒刷新一次数据
def _crc(self, data: bytes) -> bytes:
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x0001: crc = (crc >> 1) ^ 0xA001
else: crc >>= 1
return struct.pack('<H', crc)
def read_level(self) -> Optional[Dict[str, Any]]:
"""
读取液位。
返回: {'level': bool, 'rssi': int}
"""
if not self.transport:
return None
with self.transport.lock:
self.transport.clear_buffer()
# Modbus Read Registers: 01 03 00 01 00 02 CRC
payload = struct.pack('>HH', 0x0001, 0x0002)
msg = struct.pack('BB', self.device_id, 0x03) + payload
msg += self._crc(msg)
if self.debug:
self.logger.info(f"TX (ID {self.device_id}): {msg.hex().upper()}")
self.transport.write(msg)
# Read header
h = self.transport.read(3) # Addr, Func, Len
if self.debug:
self.logger.info(f"RX Header: {h.hex().upper()}")
if len(h) < 3: return None
length = h[2]
# Read body + CRC
body = self.transport.read(length + 2)
if self.debug:
self.logger.info(f"RX Body+CRC: {body.hex().upper()}")
if len(body) < length + 2:
# Firmware bug fix specific to some modules
if len(body) == 4 and length == 4:
pass
else:
return None
data = body[:-2]
# 根据手册说明:
# 寄存器 0x0001 (data[0:2]): 液位状态 (00 01 为有液, 00 00 为无液)
# 寄存器 0x0002 (data[2:4]): 信号强度 RSSI
hw_level = False
rssi = 0
if len(data) >= 4:
hw_level = ((data[0] << 8) | data[1]) == 1
rssi = (data[2] << 8) | data[3]
elif len(data) == 2:
# 兼容模式: 某些老固件可能只返回 1 个寄存器
rssi = (data[0] << 8) | data[1]
hw_level = rssi > self.threshold
else:
return None
# 最终判定: 优先使用硬件层级的 level 判定,但 RSSI 阈值逻辑作为补充/校验
# 注意: 如果用户显式设置了 THRESHOLD我们可以在逻辑中做权衡
self.level = hw_level or (rssi > self.threshold)
self.rssi = rssi
result = {
'level': self.level,
'rssi': self.rssi
}
self.status = result
return result
def wait_level(self, target_state: bool, timeout: float = 60.0) -> bool:
"""
等待液位达到目标状态 (阻塞式)
"""
self.logger.info(f"Waiting for level: {target_state}")
start_time = time.time()
while (time.time() - start_time) < timeout:
res = self.read_level()
if res and res.get('level') == target_state:
return True
time.sleep(0.5)
self.logger.warning(f"Wait level timeout ({timeout}s)")
return False
def wait_for_liquid(self, target_state: bool, timeout: float = 120.0) -> bool:
"""
实时检测电导率(RSSI)并等待用户指定的“有液”或“无液”状态。
一旦检测到符合目标状态,立即返回。
Args:
target_state: True 为“有液”, False 为“无液”
timeout: 最大等待时间(秒)
"""
state_str = "有液" if target_state else "无液"
self.logger.info(f"开始实时检测电导率,等待状态: {state_str} (超时: {timeout}s)")
start_time = time.time()
while (time.time() - start_time) < timeout:
res = self.read_level() # 内部已更新 self.level 和 self.rssi
if res:
current_level = res.get('level')
current_rssi = res.get('rssi')
if current_level == target_state:
self.logger.info(f"✅ 检测到目标状态: {state_str} (当前电导率/RSSI: {current_rssi})")
return True
if self.debug:
self.logger.debug(f"当前状态: {'有液' if current_level else '无液'}, RSSI: {current_rssi}")
time.sleep(0.2) # 高频采样
self.logger.warning(f"❌ 等待 {state_str} 状态超时 ({timeout}s)")
return False
def set_threshold(self, threshold: int):
"""设置液位判定阈值"""
self.threshold = int(threshold)
self.logger.info(f"Threshold updated to: {self.threshold}")
def change_device_id(self, new_id: int) -> bool:
"""
修改设备的 Modbus 从站地址。
寄存器: 0x0004, 功能码: 0x06
"""
if not (1 <= new_id <= 254):
self.logger.error(f"Invalid device ID: {new_id}. Must be 1-254.")
return False
self.logger.info(f"Changing device ID from {self.device_id} to {new_id}")
success = self._write_single_register(0x0004, new_id)
if success:
self.device_id = new_id # 更新内存中的地址
self.logger.info(f"Device ID update command sent successfully (target {new_id}).")
return success
def change_baudrate(self, baud_code: int) -> bool:
"""
更改通讯波特率 (寄存器: 0x0005)。
设置成功后传感器 LED 会闪烁,通常无数据返回。
波特率代码对照表 (16进制):
05: 2400
06: 4800
07: 9600 (默认)
08: 14400
09: 19200
0A: 28800
0C: 57600
0D: 115200
0E: 128000
0F: 256000
"""
self.logger.info(f"Sending baudrate change command (Code: {baud_code:02X})")
# 写入寄存器 0x0005
self._write_single_register(0x0005, baud_code)
self.logger.info("Baudrate change command executed. Device LED should flash. Please update connection settings.")
return True
def factory_reset(self) -> bool:
"""
恢复出厂设置 (通过广播地址 FF)。
设置地址为 01逻辑为向 0x0004 写入 0x0002
"""
self.logger.info("Sending factory reset command via broadcast address FF...")
# 广播指令通常无回显
self._write_single_register(0x0004, 0x0002, slave_id=0xFF)
self.logger.info("Factory reset command sent. Device address should be 01 now.")
return True
def _write_single_register(self, reg_addr: int, value: int, slave_id: Optional[int] = None) -> bool:
"""内部辅助函数: Modbus 功能码 06 写单个寄存器"""
if not self.transport: return False
target_id = slave_id if slave_id is not None else self.device_id
msg = struct.pack('BBHH', target_id, 0x06, reg_addr, value)
msg += self._crc(msg)
with self.transport.lock:
self.transport.clear_buffer()
if self.debug:
self.logger.info(f"TX Write (Reg {reg_addr:#06x}): {msg.hex().upper()}")
self.transport.write(msg)
# 广播地址、波特率修改或厂家特定指令可能无回显
if target_id == 0xFF or reg_addr == 0x0005:
time.sleep(0.5)
return True
# 等待返回 (正常应返回相同报文)
resp = self.transport.read(len(msg))
if self.debug:
self.logger.info(f"RX Write Response: {resp.hex().upper()}")
return resp == msg
def close(self):
if self.transport:
self.transport.close()
if __name__ == "__main__":
# 快速实例化测试
import logging
# 减少冗余日志,仅显示重要信息
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
# 硬件配置 (根据实际情况修改)
TEST_PORT = "/dev/tty.usbserial-3110"
SLAVE_ID = 1
THRESHOLD = 300
print("\n" + "="*50)
print(f" XKC RS485 传感器独立测试程序")
print(f" 端口: {TEST_PORT} | 地址: {SLAVE_ID} | 阈值: {THRESHOLD}")
print("="*50)
sensor = XKCSensorDriver(port=TEST_PORT, device_id=SLAVE_ID, threshold=THRESHOLD, debug=False)
try:
if sensor.transport:
print(f"\n开始实时连续采样测试 (持续 15 秒)...")
print(f"按 Ctrl+C 可提前停止\n")
start_time = time.time()
duration = 15
count = 0
while time.time() - start_time < duration:
count += 1
res = sensor.read_level()
if res:
rssi = res['rssi']
level = res['level']
status_str = "【有液】" if level else "【无液】"
# 使用 \r 实现单行刷新显示 (或者不刷,直接打印历史)
# 为了方便查看变化,我们直接打印
elapsed = time.time() - start_time
print(f" [{elapsed:4.1f}s] 采样 {count:<3}: 电导率/RSSI = {rssi:<5} | 判定结果: {status_str}")
else:
print(f" [{time.time()-start_time:4.1f}s] 采样 {count:<3}: 通信失败 (无响应)")
time.sleep(0.5) # 每秒采样 2 次
print(f"\n--- 15 秒采样测试完成 (总计 {count} 次) ---")
# [3] 测试动态修改阈值
print(f"\n[3] 动态修改阈值演示...")
new_threshold = 400
sensor.set_threshold(new_threshold)
res = sensor.read_level()
if res:
print(f" 采样 (当前阈值={new_threshold}): 电导率/RSSI = {res['rssi']:<5} | 判定结果: {'【有液】' if res['level'] else '【无液】'}")
sensor.set_threshold(THRESHOLD) # 还原
except KeyboardInterrupt:
print("\n[!] 用户中断测试")
except Exception as e:
print(f"\n[!] 测试运行出错: {e}")
finally:
sensor.close()
print("\n--- 测试程序已退出 ---\n")

View File

@@ -2,6 +2,8 @@ import time
import logging import logging
from typing import Union, Dict, Optional from typing import Union, Dict, Optional
from unilabos.registry.decorators import topic_config
class VirtualMultiwayValve: class VirtualMultiwayValve:
""" """
@@ -41,13 +43,11 @@ class VirtualMultiwayValve:
def target_position(self) -> int: def target_position(self) -> int:
return self._target_position return self._target_position
def get_current_position(self) -> int: @property
"""获取当前阀门位置 📍""" @topic_config()
return self._current_position def current_port(self) -> str:
"""当前连接的端口名称 🔌"""
def get_current_port(self) -> str: return self.port
"""获取当前连接的端口名称 🔌"""
return self._current_position
def set_position(self, command: Union[int, str]): def set_position(self, command: Union[int, str]):
""" """
@@ -169,12 +169,14 @@ class VirtualMultiwayValve:
self._status = "Idle" self._status = "Idle"
self._valve_state = "Closed" self._valve_state = "Closed"
close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.get_current_port()})" close_msg = f"🔒 阀门已关闭,保持在位置 {self._current_position} ({self.port})"
self.logger.info(close_msg) self.logger.info(close_msg)
return close_msg return close_msg
def get_valve_position(self) -> int: @property
"""获取阀门位置 - 兼容性方法 📍""" @topic_config()
def valve_position(self) -> int:
"""阀门位置 📍"""
return self._current_position return self._current_position
def set_valve_position(self, command: Union[int, str]): def set_valve_position(self, command: Union[int, str]):
@@ -229,19 +231,16 @@ class VirtualMultiwayValve:
self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...") self.logger.info(f"🔄 从端口 {self._current_position} 切换到泵位置...")
return self.set_to_pump_position() return self.set_to_pump_position()
def get_flow_path(self) -> str: @property
"""获取当前流路路径描述 🌊""" @topic_config()
current_port = self.get_current_port() def flow_path(self) -> str:
"""当前流路路径描述 🌊"""
if self._current_position == 0: if self._current_position == 0:
flow_path = f"🚰 转移泵已连接 (位置 {self._current_position})" return f"🚰 转移泵已连接 (位置 {self._current_position})"
else: return f"🔌 端口 {self._current_position} 已连接 ({self.current_port})"
flow_path = f"🔌 端口 {self._current_position} 已连接 ({current_port})"
# 删除debug日志self.logger.debug(f"🌊 当前流路: {flow_path}")
return flow_path
def __str__(self): def __str__(self):
current_port = self.get_current_port() current_port = self.current_port
status_emoji = "" if self._status == "Idle" else "🔄" if self._status == "Busy" else "" status_emoji = "" if self._status == "Idle" else "🔄" if self._status == "Busy" else ""
return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})" return f"🔄 VirtualMultiwayValve({status_emoji} 位置: {self._current_position}/{self.max_positions}, 端口: {current_port}, 状态: {self._status})"
@@ -253,7 +252,7 @@ if __name__ == "__main__":
print("🔄 === 虚拟九通阀门测试 === ✨") print("🔄 === 虚拟九通阀门测试 === ✨")
print(f"🏠 初始状态: {valve}") print(f"🏠 初始状态: {valve}")
print(f"🌊 当前流路: {valve.get_flow_path()}") print(f"🌊 当前流路: {valve.flow_path}")
# 切换到试剂瓶11号位 # 切换到试剂瓶11号位
print(f"\n🔌 切换到1号位: {valve.set_position(1)}") print(f"\n🔌 切换到1号位: {valve.set_position(1)}")

View File

@@ -0,0 +1,88 @@
"""虚拟样品演示设备 — 用于前端 sample tracking 功能的极简 demo"""
import asyncio
import logging
import random
import time
from typing import Any, Dict, List, Optional
class VirtualSampleDemo:
"""虚拟样品追踪演示设备,提供两种典型返回模式:
- measure_samples: 等长输入输出 (前端按 index 自动对齐)
- split_and_measure: 输出比输入长,附带 samples 列标注归属
"""
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
if device_id is None and "id" in kwargs:
device_id = kwargs.pop("id")
if config is None and "config" in kwargs:
config = kwargs.pop("config")
self.device_id = device_id or "unknown_sample_demo"
self.config = config or {}
self.logger = logging.getLogger(f"VirtualSampleDemo.{self.device_id}")
self.data: Dict[str, Any] = {"status": "Idle"}
# ------------------------------------------------------------------
# Action 1: 等长输入输出,无 samples 列
# ------------------------------------------------------------------
async def measure_samples(self, concentrations: List[float]) -> Dict[str, Any]:
"""模拟光度测量。absorbance = concentration * 0.05 + noise
入参和出参 list 长度相等,前端按 index 自动对齐。
"""
self.logger.info(f"measure_samples: concentrations={concentrations}")
absorbance = [round(c * 0.05 + random.gauss(0, 0.005), 4) for c in concentrations]
return {"concentrations": concentrations, "absorbance": absorbance}
# ------------------------------------------------------------------
# Action 2: 输出比输入长,带 samples 列
# ------------------------------------------------------------------
async def split_and_measure(self, volumes: List[float], split_count: int = 3) -> Dict[str, Any]:
"""将每个样品均分为 split_count 份后逐份测量。
返回的 list 长度 = len(volumes) * split_count
附带 samples 列标注每行属于第几个输入样品 (0-based index)。
"""
self.logger.info(f"split_and_measure: volumes={volumes}, split_count={split_count}")
out_volumes: List[float] = []
readings: List[float] = []
samples: List[int] = []
for idx, vol in enumerate(volumes):
split_vol = round(vol / split_count, 2)
for _ in range(split_count):
out_volumes.append(split_vol)
readings.append(round(random.uniform(0.1, 1.0), 4))
samples.append(idx)
return {"volumes": out_volumes, "readings": readings, "unilabos_samples": samples}
# ------------------------------------------------------------------
# Action 3: 入参和出参都带 samples 列(不等长)
# ------------------------------------------------------------------
async def analyze_readings(self, readings: List[float], samples: List[int]) -> Dict[str, Any]:
"""对 split_and_measure 的输出做二次分析。
入参 readings/samples 长度相同但 > 原始样品数,
出参同样带 samples 列,长度与入参一致。
"""
self.logger.info(f"analyze_readings: readings={readings}, samples={samples}")
scores: List[float] = []
passed: List[bool] = []
threshold = 0.4
for r in readings:
score = round(r * 100 + random.gauss(0, 2), 2)
scores.append(score)
passed.append(r >= threshold)
return {"scores": scores, "passed": passed, "unilabos_samples": samples}
# ------------------------------------------------------------------
# 状态属性
# ------------------------------------------------------------------
@property
def status(self) -> str:
return self.data.get("status", "Idle")

View File

@@ -3,6 +3,7 @@ import logging
import time as time_module import time as time_module
from typing import Dict, Any from typing import Dict, Any
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
class VirtualStirrer: class VirtualStirrer:
@@ -314,9 +315,11 @@ class VirtualStirrer:
def min_speed(self) -> float: def min_speed(self) -> float:
return self._min_speed return self._min_speed
def get_device_info(self) -> Dict[str, Any]: @property
"""获取设备状态信息 📊""" @topic_config()
info = { def device_info(self) -> Dict[str, Any]:
"""设备状态快照信息 📊"""
return {
"device_id": self.device_id, "device_id": self.device_id,
"status": self.status, "status": self.status,
"operation_mode": self.operation_mode, "operation_mode": self.operation_mode,
@@ -325,12 +328,9 @@ class VirtualStirrer:
"is_stirring": self.is_stirring, "is_stirring": self.is_stirring,
"remaining_time": self.remaining_time, "remaining_time": self.remaining_time,
"max_speed": self._max_speed, "max_speed": self._max_speed,
"min_speed": self._min_speed "min_speed": self._min_speed,
} }
# self.logger.debug(f"📊 设备信息: 模式={self.operation_mode}, 速度={self.current_speed} RPM, 搅拌={self.is_stirring}")
return info
def __str__(self): def __str__(self):
status_emoji = "" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else "" status_emoji = "" if self.operation_mode == "Idle" else "🌪️" if self.operation_mode == "Stirring" else "🛑" if self.operation_mode == "Settling" else ""
return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)" return f"🌪️ VirtualStirrer({status_emoji} {self.device_id}: {self.operation_mode}, {self.current_speed} RPM)"

View File

@@ -4,6 +4,7 @@ from enum import Enum
from typing import Union, Optional from typing import Union, Optional
import logging import logging
from unilabos.registry.decorators import topic_config
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
@@ -385,8 +386,10 @@ class VirtualTransferPump:
"""获取当前体积""" """获取当前体积"""
return self._current_volume return self._current_volume
def get_remaining_capacity(self) -> float: @property
"""获取剩余容量""" @topic_config()
def remaining_capacity(self) -> float:
"""剩余容量 (ml)"""
return self.max_volume - self._current_volume return self.max_volume - self._current_volume
def is_empty(self) -> bool: def is_empty(self) -> bool:

View File

@@ -22,10 +22,11 @@ from threading import Lock, RLock
from typing_extensions import TypedDict from typing_extensions import TypedDict
from unilabos.registry.decorators import ( from unilabos.registry.decorators import (
device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action device, action, ActionInputHandle, ActionOutputHandle, DataSource, topic_config, not_action, NodeType
) )
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, ResourceTreeSet
# ============ TypedDict 返回类型定义 ============ # ============ TypedDict 返回类型定义 ============
@@ -290,6 +291,126 @@ class VirtualWorkbench:
self._update_data_status(f"机械臂已释放 (完成: {task})") self._update_data_status(f"机械臂已释放 (完成: {task})")
self.logger.info(f"机械臂已释放 (完成: {task})") self.logger.info(f"机械臂已释放 (完成: {task})")
@action(
always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
"assignee_user_ids": "unilabos_manual_confirm"
}, goal_default={
"timeout_seconds": 3600,
"assignee_user_ids": []
}, feedback_interval=300,
handles=[
ActionInputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
ActionInputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
ActionInputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
ActionInputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
# transfer使用
ActionOutputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource.@flatten", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource.@flatten", data_source=DataSource.EXECUTOR),
# test使用
ActionOutputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.EXECUTOR),
ActionOutputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.EXECUTOR),
]
)
def manual_confirm(
self,
resource: List[ResourceSlot],
target_device: DeviceSlot,
mount_resource: List[ResourceSlot],
collector_mass: List[float],
active_material: List[float],
capacity: List[float],
battery_system: List[str],
timeout_seconds: int,
assignee_user_ids: list[str],
**kwargs
) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
collector_mass: 极流体质量
active_material: 活性物质含量
capacity: 克容量mAh/g
battery_system: 电池体系
修改的结果无效,是只读的
"""
resource = ResourceTreeSet.from_plr_resources(resource).dump()
mount_resource = ResourceTreeSet.from_plr_resources(mount_resource).dump()
kwargs.update(locals())
kwargs.pop("kwargs")
kwargs.pop("self")
return kwargs
@action(
description="转移物料",
handles=[
ActionInputHandle(key="target_device", data_type="device_id",
label="目标设备", data_key="target_device", data_source=DataSource.HANDLE),
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
]
)
async def transfer(self, resource: List[ResourceSlot], target_device: DeviceSlot, mount_resource: List[ResourceSlot]):
future = ROS2DeviceNode.run_async_func(self._ros_node.transfer_resource_to_another, True,
**{
"plr_resources": resource,
"target_device_id": target_device,
"target_resources": mount_resource,
"sites": [None] * len(mount_resource),
})
result = await future
return result
@action(
description="扣电测试启动",
handles=[
ActionInputHandle(key="resource", data_type="resource",
label="待转移资源", data_key="resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="mount_resource", data_type="resource",
label="目标孔位", data_key="mount_resource", data_source=DataSource.HANDLE),
ActionInputHandle(key="collector_mass", data_type="collector_mass",
label="极流体质量", data_key="collector_mass", data_source=DataSource.HANDLE),
ActionInputHandle(key="active_material", data_type="active_material",
label="活性物质含量", data_key="active_material", data_source=DataSource.HANDLE),
ActionInputHandle(key="capacity", data_type="capacity",
label="克容量", data_key="capacity", data_source=DataSource.HANDLE),
ActionInputHandle(key="battery_system", data_type="battery_system",
label="电池体系", data_key="battery_system", data_source=DataSource.HANDLE),
]
)
async def test(
self, resource: List[ResourceSlot], mount_resource: List[ResourceSlot], collector_mass: List[float], active_material: List[float], capacity: List[float], battery_system: list[str]
):
print(resource)
print(mount_resource)
print(collector_mass)
print(active_material)
print(capacity)
print(battery_system)
@action( @action(
auto_prefix=True, auto_prefix=True,
description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用", description="批量准备物料 - 虚拟起始节点, 生成A1-A5物料, 输出5个handle供后续节点使用",

View File

@@ -258,7 +258,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
logger.info(f"[同步→Bioyond] 物料不存在于 Bioyond将创建新物料并入库") logger.info(f"[同步→Bioyond] 物料不存在于 Bioyond将创建新物料并入库")
# 第1步从配置中获取仓库配置 # 第1步从配置中获取仓库配置
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {}) warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
# 确定目标仓库名称 # 确定目标仓库名称
parent_name = None parent_name = None

View File

@@ -139,6 +139,7 @@ def scan_directory(
executor: ThreadPoolExecutor = None, executor: ThreadPoolExecutor = None,
exclude_files: Optional[set] = None, exclude_files: Optional[set] = None,
cache: Optional[Dict[str, Any]] = None, cache: Optional[Dict[str, Any]] = None,
include_files: Optional[List[Union[str, Path]]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Recursively scan .py files under *root_dir* for @device and @resource Recursively scan .py files under *root_dir* for @device and @resource
@@ -164,6 +165,7 @@ def scan_directory(
exclude_files: 要排除的文件名集合 (如 {"lab_resources.py"}) exclude_files: 要排除的文件名集合 (如 {"lab_resources.py"})
cache: Mutable cache dict (``load_scan_cache()`` result). Hits are read cache: Mutable cache dict (``load_scan_cache()`` result). Hits are read
from here; misses are written back so the caller can persist later. from here; misses are written back so the caller can persist later.
include_files: 指定扫描的文件列表,提供时跳过目录递归收集,直接扫描这些文件。
""" """
if executor is None: if executor is None:
raise ValueError("executor is required and must not be None") raise ValueError("executor is required and must not be None")
@@ -175,7 +177,10 @@ def scan_directory(
python_path = Path(python_path).resolve() python_path = Path(python_path).resolve()
# --- Collect files (depth/count limited) --- # --- Collect files (depth/count limited) ---
py_files = _collect_py_files(root_dir, max_depth=max_depth, max_files=max_files, exclude_files=exclude_files) if include_files is not None:
py_files = [Path(f).resolve() for f in include_files if Path(f).resolve().exists()]
else:
py_files = _collect_py_files(root_dir, max_depth=max_depth, max_files=max_files, exclude_files=exclude_files)
cache_files: Dict[str, Any] = cache.get("files", {}) if cache else {} cache_files: Dict[str, Any] = cache.get("files", {}) if cache else {}
@@ -674,14 +679,17 @@ def _resolve_name(name: str, import_map: Dict[str, str]) -> str:
return name return name
_DECORATOR_ENUM_CLASSES = frozenset({"Side", "DataSource", "NodeType"})
def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str: def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
""" """
Resolve an attribute access like Side.NORTH or DataSource.HANDLE. Resolve an attribute access like Side.NORTH or DataSource.HANDLE.
Returns a string like "NORTH" for enum values, or 对于来自 ``unilabos.registry.decorators`` 的枚举类 (Side / DataSource / NodeType)
"module.path:Class.attr" for imported references. 直接返回枚举成员名 (如 ``"NORTH"`` / ``"HANDLE"`` / ``"MANUAL_CONFIRM"``)
省去消费端二次 rsplit 解析。其它 import 仍返回完整模块路径。
""" """
# Get the full dotted path
parts = [] parts = []
current = node current = node
while isinstance(current, ast.Attribute): while isinstance(current, ast.Attribute):
@@ -691,21 +699,20 @@ def _resolve_attribute(node: ast.Attribute, import_map: Dict[str, str]) -> str:
parts.append(current.id) parts.append(current.id)
parts.reverse() parts.reverse()
# parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"] # parts = ["Side", "NORTH"] or ["DataSource", "HANDLE"] or ["NodeType", "MANUAL_CONFIRM"]
if len(parts) >= 2: if len(parts) >= 2:
base = parts[0] base = parts[0]
attr = ".".join(parts[1:]) attr = ".".join(parts[1:])
# If the base is an imported name, resolve it if base in _DECORATOR_ENUM_CLASSES:
source = import_map.get(base, "")
if not source or _REGISTRY_DECORATOR_MODULE in source:
return parts[-1]
if base in import_map: if base in import_map:
return f"{import_map[base]}.{attr}" return f"{import_map[base]}.{attr}"
# For known enum-like patterns, return just the value
# e.g. Side.NORTH -> "NORTH"
if base in ("Side", "DataSource"):
return parts[-1]
return ".".join(parts) return ".".join(parts)
@@ -818,6 +825,7 @@ def _extract_class_body(
action_args.setdefault("placeholder_keys", {}) action_args.setdefault("placeholder_keys", {})
action_args.setdefault("always_free", False) action_args.setdefault("always_free", False)
action_args.setdefault("is_protocol", False) action_args.setdefault("is_protocol", False)
action_args.setdefault("feedback_interval", 1.0)
action_args.setdefault("description", "") action_args.setdefault("description", "")
action_args.setdefault("auto_prefix", False) action_args.setdefault("auto_prefix", False)
action_args.setdefault("parent", False) action_args.setdefault("parent", False)

View File

@@ -8,7 +8,7 @@ Usage:
device, action, resource, device, action, resource,
InputHandle, OutputHandle, InputHandle, OutputHandle,
ActionInputHandle, ActionOutputHandle, ActionInputHandle, ActionOutputHandle,
HardwareInterface, Side, DataSource, HardwareInterface, Side, DataSource, NodeType,
) )
@device( @device(
@@ -73,6 +73,13 @@ class DataSource(str, Enum):
EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle) EXECUTOR = "executor" # 从执行器输出数据 (用于 OutputHandle)
class NodeType(str, Enum):
"""动作的节点类型(用于区分 ILab 节点和人工确认节点等)"""
ILAB = "ILab"
MANUAL_CONFIRM = "manual_confirm"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type) # Device / Resource Handle (设备/资源级别端口, 序列化时包含 io_type)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -335,6 +342,8 @@ def action(
description: str = "", description: str = "",
auto_prefix: bool = False, auto_prefix: bool = False,
parent: bool = False, parent: bool = False,
node_type: Optional["NodeType"] = None,
feedback_interval: Optional[float] = None,
): ):
""" """
动作方法装饰器 动作方法装饰器
@@ -365,12 +374,21 @@ def action(
description: 动作描述 description: 动作描述
auto_prefix: 若为 True动作名使用 auto-{method_name} 形式(与无 @action 时一致) auto_prefix: 若为 True动作名使用 auto-{method_name} 形式(与无 @action 时一致)
parent: 若为 True当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数 parent: 若为 True当方法参数为空 (*args, **kwargs) 时,通过 MRO 从父类获取真实方法参数
node_type: 动作的节点类型 (NodeType.ILAB / NodeType.MANUAL_CONFIRM)。
不填写时不写入注册表。
""" """
def decorator(func: F) -> F: def decorator(func: F) -> F:
@wraps(func) import asyncio as _asyncio
def wrapper(*args, **kwargs):
return func(*args, **kwargs) if _asyncio.iscoroutinefunction(func):
@wraps(func)
async def wrapper(*args, **kwargs):
return await func(*args, **kwargs)
else:
@wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
# action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand) # action_type 为哨兵值 => 用户没传, 视为 None (UniLabJsonCommand)
resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type resolved_type = None if action_type is _ACTION_TYPE_UNSET else action_type
@@ -389,6 +407,10 @@ def action(
"auto_prefix": auto_prefix, "auto_prefix": auto_prefix,
"parent": parent, "parent": parent,
} }
if feedback_interval is not None:
meta["feedback_interval"] = feedback_interval
if node_type is not None:
meta["node_type"] = node_type.value if isinstance(node_type, NodeType) else str(node_type)
wrapper._action_registry_meta = meta # type: ignore[attr-defined] wrapper._action_registry_meta = meta # type: ignore[attr-defined]
# 设置 _is_always_free 保持与旧 @always_free 装饰器兼容 # 设置 _is_always_free 保持与旧 @always_free 装饰器兼容
@@ -515,6 +537,38 @@ def clear_registry():
_registered_resources.clear() _registered_resources.clear()
# ---------------------------------------------------------------------------
# 枚举值归一化
# ---------------------------------------------------------------------------
def normalize_enum_value(raw: Any, enum_cls) -> Optional[str]:
"""将 AST 提取的枚举成员名 / YAML 值字符串 / 旧格式长路径统一归一化为枚举值。
适用于 Side、DataSource、NodeType 等继承自 ``str, Enum`` 的装饰器枚举。
处理以下格式:
- "MANUAL_CONFIRM" → NodeType["MANUAL_CONFIRM"].value = "manual_confirm"
- "manual_confirm" → NodeType("manual_confirm").value = "manual_confirm"
- "HANDLE" → DataSource["HANDLE"].value = "handle"
- "NORTH" → Side["NORTH"].value = "NORTH"
- 旧缓存长路径 "unilabos...NodeType.MANUAL_CONFIRM" → 先 rsplit 再查找
"""
if not raw:
return None
raw_str = str(raw)
if "." in raw_str:
raw_str = raw_str.rsplit(".", 1)[-1]
try:
return enum_cls[raw_str].value
except KeyError:
pass
try:
return enum_cls(raw_str).value
except ValueError:
return raw_str
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# topic_config / not_action / always_free 装饰器 # topic_config / not_action / always_free 装饰器
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -336,6 +336,47 @@ separator.chinwe:
title: pump_valve参数 title: pump_valve参数
type: object type: object
type: UniLabJsonCommand type: UniLabJsonCommand
separation_step:
goal:
max_cycles: 0
motor_id: 5
pulses: 700
speed: 60
timeout: 300
handles: {}
schema:
description: 分液步骤 - 液位传感器与电机联动 (有液→顺时针, 无液→逆时针)
properties:
goal:
properties:
max_cycles:
default: 0
description: 最大循环次数 (0=无限制)
type: integer
motor_id:
default: '5'
description: 选择电机
enum:
- '4'
- '5'
title: '注: 4=搅拌, 5=旋钮'
type: string
pulses:
default: 700
description: 每次旋转脉冲数 (约1/4圈)
type: integer
speed:
default: 60
description: 电机转速 (RPM)
type: integer
timeout:
default: 300
description: 超时时间 (秒)
type: integer
required:
- motor_id
type: object
type: UniLabJsonCommand
wait_sensor_level: wait_sensor_level:
feedback: {} feedback: {}
goal: goal:

View File

@@ -31,6 +31,6 @@ hotel.thermo_orbitor_rs2_hotel:
type: object type: object
model: model:
mesh: thermo_orbitor_rs2_hotel mesh: thermo_orbitor_rs2_hotel
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/thermo_orbitor_rs2_hotel/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0

View File

@@ -6973,7 +6973,7 @@ liquid_handler.laiyu:
properties: properties:
channel_num: channel_num:
default: 1 default: 1
type: string type: integer
deck: deck:
type: object type: object
host: host:
@@ -6984,10 +6984,25 @@ liquid_handler.laiyu:
type: integer type: integer
simulator: simulator:
default: true default: true
type: string type: boolean
timeout: timeout:
default: 10.0 default: 10.0
type: number type: number
serial_port:
default: /dev/ttyUSB0
description: 硬件串口端口(非 simulator 模式下使用)
type: string
baudrate:
default: 115200
type: integer
pipette_address:
default: 4
description: SOPA 移液器 RS485 地址
type: integer
total_height:
default: 310
description: 龙门架总高度 (mm),用于坐标转换
type: number
required: required:
- deck - deck
type: object type: object

View File

@@ -0,0 +1,286 @@
motor.zdt_x42:
category:
- motor
class:
action_value_mappings:
auto-enable:
feedback: {}
goal: {}
goal_default:
'on': true
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 使能或禁用电机。使能后电机进入锁轴状态,可接收运动指令;禁用后电机进入松轴状态。
properties:
feedback: {}
goal:
properties:
'on':
default: true
type: boolean
required: []
type: object
result: {}
required:
- goal
title: enable参数
type: object
type: UniLabJsonCommand
auto-get_position:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 获取当前电机脉冲位置。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result:
properties:
position:
type: integer
type: object
required:
- goal
title: get_position参数
type: object
type: UniLabJsonCommand
auto-move_position:
feedback: {}
goal: {}
goal_default:
absolute: false
acceleration: 10
direction: CW
pulses: 1000
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 位置模式运行。控制电机移动到指定脉冲位置或相对于当前位置移动指定脉冲数。
properties:
feedback: {}
goal:
properties:
absolute:
default: false
type: boolean
acceleration:
default: 10
maximum: 255
minimum: 0
type: integer
direction:
default: CW
enum:
- CW
- CCW
type: string
pulses:
default: 1000
type: integer
speed_rpm:
default: 60
minimum: 0
type: integer
required:
- pulses
- speed_rpm
type: object
result: {}
required:
- goal
title: move_position参数
type: object
type: UniLabJsonCommand
auto-move_speed:
feedback: {}
goal: {}
goal_default:
acceleration: 10
direction: CW
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 速度模式运行。控制电机以指定转速和方向持续转动。
properties:
feedback: {}
goal:
properties:
acceleration:
default: 10
maximum: 255
minimum: 0
type: integer
direction:
default: CW
enum:
- CW
- CCW
type: string
speed_rpm:
default: 60
minimum: 0
type: integer
required:
- speed_rpm
type: object
result: {}
required:
- goal
title: move_speed参数
type: object
type: UniLabJsonCommand
auto-rotate_quarter:
feedback: {}
goal: {}
goal_default:
direction: CW
speed_rpm: 60
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 电机旋转 1/4 圈 (阻塞式)。
properties:
feedback: {}
goal:
properties:
direction:
default: CW
enum:
- CW
- CCW
type: string
speed_rpm:
default: 60
minimum: 1
type: integer
required: []
type: object
result: {}
required:
- goal
title: rotate_quarter参数
type: object
type: UniLabJsonCommand
auto-set_zero:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 将当前电机位置设为零点。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: set_zero参数
type: object
type: UniLabJsonCommand
auto-stop:
feedback: {}
goal: {}
goal_default: {}
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 立即停止电机运动。
properties:
feedback: {}
goal:
properties: {}
required: []
type: object
result: {}
required:
- goal
title: stop参数
type: object
type: UniLabJsonCommand
auto-wait_time:
feedback: {}
goal: {}
goal_default:
duration_s: 1.0
handles: {}
placeholder_keys: {}
result: {}
schema:
description: 等待指定时间 (秒)。
properties:
feedback: {}
goal:
properties:
duration_s:
default: 1.0
minimum: 0
type: number
required:
- duration_s
type: object
result: {}
required:
- goal
title: wait_time参数
type: object
type: UniLabJsonCommand
module: unilabos.devices.motor.ZDT_X42:ZDTX42Driver
status_types:
position: int
status: str
type: python
config_info: []
description: ZDT X42 闭环步进电机驱动。支持速度运行、精确位置控制、位置查询和清零功能。适用于各种需要精确运动控制的实验室自动化场景。
handles: []
icon: ''
init_param_schema:
config:
properties:
baudrate:
default: 115200
type: integer
debug:
default: false
type: boolean
device_id:
default: 1
type: integer
port:
type: string
timeout:
default: 0.5
type: number
required:
- port
type: object
data:
properties:
position:
type: integer
status:
type: string
required:
- status
- position
type: object
version: 1.0.0

View File

@@ -329,7 +329,7 @@ robotic_arm.SCARA_with_slider.moveit.virtual:
type: object type: object
model: model:
mesh: arm_slider mesh: arm_slider
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/arm_slider/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0
robotic_arm.UR: robotic_arm.UR:

View File

@@ -0,0 +1,148 @@
sensor.xkc_rs485:
category:
- sensor
- separator
class:
action_value_mappings:
auto-change_baudrate:
goal:
baud_code: 7
handles: {}
schema:
description: '更改通讯波特率 (设置成功后无返回,且需手动切换波特率重连)。代码表 (16进制): 05=2400, 06=4800,
07=9600, 08=14400, 09=19200, 0A=28800, 0C=57600, 0D=115200, 0E=128000,
0F=256000'
properties:
goal:
properties:
baud_code:
description: '波特率代码 (例如: 7 为 9600, 13 即 0x0D 为 115200)'
type: integer
required:
- baud_code
type: object
type: UniLabJsonCommand
auto-change_device_id:
goal:
new_id: 1
handles: {}
schema:
description: 修改传感器的 Modbus 从站地址
properties:
goal:
properties:
new_id:
description: 新的从站地址 (1-254)
maximum: 254
minimum: 1
type: integer
required:
- new_id
type: object
type: UniLabJsonCommand
auto-factory_reset:
goal: {}
handles: {}
schema:
description: 恢复出厂设置 (地址重置为 01)
properties:
goal:
type: object
type: UniLabJsonCommand
auto-read_level:
goal: {}
handles: {}
schema:
description: 直接读取当前液位及信号强度
properties:
goal:
type: object
type: object
type: UniLabJsonCommand
auto-set_threshold:
goal:
threshold: 300
handles: {}
schema:
description: 设置液位判定阈值
properties:
goal:
properties:
threshold:
type: integer
required:
- threshold
type: object
type: UniLabJsonCommand
auto-wait_for_liquid:
goal:
target_state: true
timeout: 120
handles: {}
schema:
description: 实时检测电导率(RSSI)并等待用户指定的状态
properties:
goal:
properties:
target_state:
default: true
description: 目标状态 (True=有液, False=无液)
type: boolean
timeout:
default: 120
description: 超时时间 (秒)
required:
- target_state
type: object
type: UniLabJsonCommand
auto-wait_level:
goal:
level: true
timeout: 10
handles: {}
schema:
description: 等待液位达到目标状态
properties:
goal:
properties:
level:
type: boolean
timeout:
type: number
required:
- level
type: object
type: UniLabJsonCommand
module: unilabos.devices.separator.xkc_sensor:XKCSensorDriver
status_types:
level: bool
rssi: int
type: python
config_info: []
description: XKC RS485 非接触式液位传感器 (Modbus RTU)
handles: []
icon: ''
init_param_schema:
config:
properties:
baudrate:
default: 9600
type: integer
debug:
default: false
type: boolean
device_id:
default: 1
type: integer
port:
type: string
threshold:
default: 300
type: integer
timeout:
default: 3.0
type: number
required:
- port
type: object
version: 1.0.0

View File

@@ -2804,6 +2804,203 @@ virtual_rotavap:
- vacuum_pressure - vacuum_pressure
type: object type: object
version: 1.0.0 version: 1.0.0
virtual_sample_demo:
category:
- virtual_device
class:
action_value_mappings:
analyze_readings:
feedback: {}
goal:
readings: readings
samples: samples
goal_default:
readings: null
samples: null
handles:
input:
- data_key: readings
data_source: handle
data_type: sample_list
handler_key: readings_in
label: 测量读数
- data_key: samples
data_source: handle
data_type: sample_index
handler_key: samples_in
label: 样品索引
output:
- data_key: scores
data_source: executor
data_type: sample_list
handler_key: scores_out
label: 分析得分
- data_key: passed
data_source: executor
data_type: sample_list
handler_key: passed_out
label: 是否通过
- data_key: samples
data_source: executor
data_type: sample_index
handler_key: samples_result_out
label: 样品索引
placeholder_keys: {}
result: {}
schema:
description: 对 split_and_measure 输出做二次分析,入参和出参都带 samples 列
properties:
feedback:
title: AnalyzeReadings_Feedback
goal:
properties:
readings:
description: 测量读数(来自 split_and_measure
items:
type: number
type: array
samples:
description: 每行归属的输入样品 index (0-based)
items:
type: integer
type: array
required:
- readings
- samples
title: AnalyzeReadings_Goal
type: object
result:
title: AnalyzeReadings_Result
type: object
required:
- goal
title: analyze_readings参数
type: object
type: UniLabJsonCommandAsync
measure_samples:
feedback: {}
goal:
concentrations: concentrations
goal_default:
concentrations: null
handles:
output:
- data_key: concentrations
data_source: executor
data_type: sample_list
handler_key: concentrations_out
label: 浓度列表
- data_key: absorbance
data_source: executor
data_type: sample_list
handler_key: absorbance_out
label: 吸光度列表
placeholder_keys: {}
result: {}
schema:
description: 模拟光度测量,入参出参等长
properties:
feedback:
title: MeasureSamples_Feedback
goal:
properties:
concentrations:
description: 样品浓度列表
items:
type: number
type: array
required:
- concentrations
title: MeasureSamples_Goal
type: object
result:
title: MeasureSamples_Result
type: object
required:
- goal
title: measure_samples参数
type: object
type: UniLabJsonCommandAsync
split_and_measure:
feedback: {}
goal:
split_count: split_count
volumes: volumes
goal_default:
split_count: 3
volumes: null
handles:
output:
- data_key: readings
data_source: executor
data_type: sample_list
handler_key: readings_out
label: 测量读数
- data_key: samples
data_source: executor
data_type: sample_index
handler_key: samples_out
label: 样品索引
- data_key: volumes
data_source: executor
data_type: sample_list
handler_key: volumes_out
label: 均分体积
placeholder_keys: {}
result: {}
schema:
description: 均分样品后逐份测量,输出带 samples 列标注归属
properties:
feedback:
title: SplitAndMeasure_Feedback
goal:
properties:
split_count:
default: 3
description: 每个样品均分的份数
type: integer
volumes:
description: 样品体积列表
items:
type: number
type: array
required:
- volumes
title: SplitAndMeasure_Goal
type: object
result:
title: SplitAndMeasure_Result
type: object
required:
- goal
title: split_and_measure参数
type: object
type: UniLabJsonCommandAsync
module: unilabos.devices.virtual.virtual_sample_demo:VirtualSampleDemo
status_types:
status: str
type: python
config_info: []
description: Virtual sample tracking demo device
handles: []
icon: ''
init_param_schema:
config:
properties:
config:
type: object
device_id:
type: string
required: []
type: object
data:
properties:
status:
type: string
required:
- status
type: object
version: 1.0.0
virtual_separator: virtual_separator:
category: category:
- virtual_device - virtual_device
@@ -3763,6 +3960,14 @@ virtual_separator:
io_type: source io_type: source
label: bottom_phase_out label: bottom_phase_out
side: SOUTH side: SOUTH
- data_key: top_outlet
data_source: executor
data_type: fluid
description: 上相(轻相)液体输出口
handler_key: topphaseout
io_type: source
label: top_phase_out
side: NORTH
- data_key: mechanical_port - data_key: mechanical_port
data_source: handle data_source: handle
data_type: mechanical data_type: mechanical

View File

@@ -33,6 +33,8 @@ from unilabos.registry.decorators import (
is_not_action, is_not_action,
is_always_free, is_always_free,
get_topic_config, get_topic_config,
NodeType,
normalize_enum_value,
) )
from unilabos.registry.utils import ( from unilabos.registry.utils import (
ROSMsgNotFound, ROSMsgNotFound,
@@ -112,7 +114,7 @@ class Registry:
# 统一入口 # 统一入口
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False): def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False, external_only=False):
"""统一构建注册表入口。""" """统一构建注册表入口。"""
if self._setup_called: if self._setup_called:
logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用") logger.critical("[UniLab Registry] setup方法已被调用过不允许多次调用")
@@ -123,24 +125,27 @@ class Registry:
) )
# 1. AST 静态扫描 (快速, 无需 import) # 1. AST 静态扫描 (快速, 无需 import)
self._run_ast_scan(devices_dirs, upload_registry=upload_registry) self._run_ast_scan(devices_dirs, upload_registry=upload_registry, external_only=external_only)
# 2. Host node 内置设备 # 2. Host node 内置设备
self._setup_host_node() self._setup_host_node()
# 3. YAML 注册表加载 (兼容旧格式) # 3. YAML 注册表加载 (兼容旧格式) — external_only 模式下跳过
self.registry_paths = [Path(path).absolute() for path in self.registry_paths] if external_only:
for i, path in enumerate(self.registry_paths): logger.info("[UniLab Registry] external_only 模式: 跳过 YAML 注册表加载")
sys_path = path.parent else:
logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}") self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
sys.path.append(str(sys_path)) for i, path in enumerate(self.registry_paths):
self.load_device_types(path, complete_registry=complete_registry) sys_path = path.parent
if BasicConfig.enable_resource_load: logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}")
self.load_resource_types(path, upload_registry, complete_registry=complete_registry) sys.path.append(str(sys_path))
else: self.load_device_types(path, complete_registry=complete_registry)
logger.warning( if BasicConfig.enable_resource_load:
"[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载" self.load_resource_types(path, upload_registry, complete_registry=complete_registry)
) else:
logger.warning(
"[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载"
)
self._startup_executor.shutdown(wait=True) self._startup_executor.shutdown(wait=True)
self._startup_executor = None self._startup_executor = None
self._setup_called = True self._setup_called = True
@@ -156,9 +161,10 @@ class Registry:
ast_entry = self.device_type_registry.get("host_node", {}) ast_entry = self.device_type_registry.get("host_node", {})
ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {}) ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {})
# 取出 AST 生成的 auto-method entries, 补充特定覆写 # 取出 AST 生成的 action entries, 补充特定覆写
test_latency_action = ast_actions.get("auto-test_latency", {}) test_latency_action = ast_actions.get("auto-test_latency", {})
test_resource_action = ast_actions.get("auto-test_resource", {}) test_resource_action = ast_actions.get("auto-test_resource", {})
manual_confirm_action = ast_actions.get("manual_confirm", {})
test_resource_action["handles"] = { test_resource_action["handles"] = {
"input": [ "input": [
{ {
@@ -231,9 +237,12 @@ class Registry:
"parent": "unilabos_nodes", "parent": "unilabos_nodes",
"class_name": "unilabos_class", "class_name": "unilabos_class",
}, },
"always_free": True,
"feedback_interval": 300.0,
}, },
"test_latency": test_latency_action, "test_latency": test_latency_action,
"auto-test_resource": test_resource_action, "auto-test_resource": test_resource_action,
"manual_confirm": manual_confirm_action,
}, },
"init_params": {}, "init_params": {},
}, },
@@ -253,7 +262,7 @@ class Registry:
# AST 静态扫描 # AST 静态扫描
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _run_ast_scan(self, devices_dirs=None, upload_registry=False): def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only=False):
""" """
执行 AST 静态扫描,从 Python 代码中提取 @device / @resource 装饰器元数据。 执行 AST 静态扫描,从 Python 代码中提取 @device / @resource 装饰器元数据。
无需 import 任何驱动模块,速度极快。 无需 import 任何驱动模块,速度极快。
@@ -298,16 +307,30 @@ class Registry:
extra_dirs.append(d_path) extra_dirs.append(d_path)
# 主扫描 # 主扫描
exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None if external_only:
scan_result = scan_directory( core_files = [
scan_root, python_path=python_path, executor=self._startup_executor, pkg_root / "ros" / "nodes" / "presets" / "host_node.py",
exclude_files=exclude_files, cache=ast_cache, pkg_root / "resources" / "container.py",
) ]
if exclude_files: scan_result = scan_directory(
logger.info( scan_root, python_path=python_path, executor=self._startup_executor,
f"[UniLab Registry] 排除扫描文件: {exclude_files} " cache=ast_cache, include_files=core_files,
f"(可通过 --extra_resource 启用加载)"
) )
logger.info(
f"[UniLab Registry] external_only 模式: 仅扫描核心文件 "
f"({', '.join(f.name for f in core_files)})"
)
else:
exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None
scan_result = scan_directory(
scan_root, python_path=python_path, executor=self._startup_executor,
exclude_files=exclude_files, cache=ast_cache,
)
if exclude_files:
logger.info(
f"[UniLab Registry] 排除扫描文件: {exclude_files} "
f"(可通过 --extra_resource 启用加载)"
)
# 合并缓存统计 # 合并缓存统计
total_stats = scan_result.pop("_cache_stats", {"hits": 0, "misses": 0, "total": 0}) total_stats = scan_result.pop("_cache_stats", {"hits": 0, "misses": 0, "total": 0})
@@ -807,8 +830,9 @@ class Registry:
raw_handles = (action_args or {}).get("handles") raw_handles = (action_args or {}).get("handles")
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {}) handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
# placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测 # placeholder_keys: 先从参数类型自动检测,再用装饰器显式配置覆盖/补充
pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params) pk = detect_placeholder_keys(params)
pk.update((action_args or {}).get("placeholder_keys") or {})
# 从方法返回值类型生成 result schema # 从方法返回值类型生成 result schema
result_schema = None result_schema = None
@@ -830,6 +854,11 @@ class Registry:
} }
if (action_args or {}).get("always_free") or method_info.get("always_free"): if (action_args or {}).get("always_free") or method_info.get("always_free"):
entry["always_free"] = True entry["always_free"] = True
_fb_iv = (action_args or {}).get("feedback_interval", method_info.get("feedback_interval", 1.0))
entry["feedback_interval"] = _fb_iv
nt = normalize_enum_value((action_args or {}).get("node_type"), NodeType)
if nt:
entry["node_type"] = nt
return action_name, entry return action_name, entry
# 1) auto- actions # 1) auto- actions
@@ -950,10 +979,15 @@ class Registry:
"schema": schema, "schema": schema,
"goal_default": goal_default, "goal_default": goal_default,
"handles": handles, "handles": handles,
"placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params), "placeholder_keys": {**detect_placeholder_keys(method_params), **(action_args.get("placeholder_keys") or {})},
} }
if action_args.get("always_free") or method_info.get("always_free"): if action_args.get("always_free") or method_info.get("always_free"):
action_entry["always_free"] = True action_entry["always_free"] = True
_fb_iv = action_args.get("feedback_interval", method_info.get("feedback_interval", 1.0))
action_entry["feedback_interval"] = _fb_iv
nt = normalize_enum_value(action_args.get("node_type"), NodeType)
if nt:
action_entry["node_type"] = nt
action_value_mappings[action_name] = action_entry action_value_mappings[action_name] = action_entry
action_value_mappings = dict(sorted(action_value_mappings.items())) action_value_mappings = dict(sorted(action_value_mappings.items()))
@@ -1136,7 +1170,7 @@ class Registry:
return Path(BasicConfig.working_dir) / "registry_cache.pkl" return Path(BasicConfig.working_dir) / "registry_cache.pkl"
return None return None
_CACHE_VERSION = 3 _CACHE_VERSION = 4
def _load_config_cache(self) -> dict: def _load_config_cache(self) -> dict:
import pickle import pickle
@@ -1534,9 +1568,9 @@ class Registry:
del resource_info["config_info"] del resource_info["config_info"]
if "file_path" in resource_info: if "file_path" in resource_info:
del resource_info["file_path"] del resource_info["file_path"]
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
resource_info["registry_type"] = "resource" resource_info["registry_type"] = "resource"
resource_info["file_path"] = str(file.absolute()).replace("\\", "/") resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
for rid in skip_ids: for rid in skip_ids:
data.pop(rid, None) data.pop(rid, None)
@@ -1861,6 +1895,9 @@ class Registry:
} }
if v.get("always_free"): if v.get("always_free"):
entry["always_free"] = True entry["always_free"] = True
old_node_type = old_cfg.get("node_type")
if old_node_type in [NodeType.ILAB.value, NodeType.MANUAL_CONFIRM.value]:
entry["node_type"] = old_node_type
device_config["class"]["action_value_mappings"][action_key] = entry device_config["class"]["action_value_mappings"][action_key] = entry
device_config["init_param_schema"] = {} device_config["init_param_schema"] = {}
@@ -2175,7 +2212,7 @@ class Registry:
lab_registry = Registry() lab_registry = Registry()
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False): def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False):
""" """
构建或获取Registry单例实例 构建或获取Registry单例实例
""" """
@@ -2189,7 +2226,7 @@ def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False
if path not in current_paths: if path not in current_paths:
lab_registry.registry_paths.append(path) lab_registry.registry_paths.append(path)
lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry) lab_registry.setup(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only)
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块) # 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
lab_registry.resolve_all_types() lab_registry.resolve_all_types()

View File

@@ -42,3 +42,16 @@ BIOYOND_PolymerStation_8StockCarrier:
icon: '' icon: ''
init_param_schema: {} init_param_schema: {}
version: 1.0.0 version: 1.0.0
BIOYOND_PolymerStation_TipBox:
category:
- bottle_carriers
- tip_racks
class:
module: unilabos.resources.bioyond.bottle_carriers:BIOYOND_PolymerStation_TipBox
type: pylabrobot
description: BIOYOND_PolymerStation_TipBox (4x6布局24个枪头孔位)
handles: []
icon: ''
init_param_schema: {}
registry_type: resource
version: 1.0.0

View File

@@ -82,14 +82,3 @@ BIOYOND_PolymerStation_Solution_Beaker:
icon: '' icon: ''
init_param_schema: {} init_param_schema: {}
version: 1.0.0 version: 1.0.0
BIOYOND_PolymerStation_TipBox:
category:
- bottles
- tip_boxes
class:
module: unilabos.resources.bioyond.bottles:BIOYOND_PolymerStation_TipBox
type: pylabrobot
handles: []
icon: ''
init_param_schema: {}
version: 1.0.0

View File

@@ -17,7 +17,7 @@ hplc_plate:
- 0 - 0
- 0 - 0
- 3.1416 - 3.1416
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/hplc_plate/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
plate_96: plate_96:
@@ -39,7 +39,7 @@ plate_96:
- 0 - 0
- 0 - 0
- 0 - 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
plate_96_high: plate_96_high:
@@ -61,7 +61,7 @@ plate_96_high:
- 1.5708 - 1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/plate_96_high/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
tiprack_96_high: tiprack_96_high:
@@ -76,7 +76,7 @@ tiprack_96_high:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.0018 - 0.0018
- 0.0018 - 0.0018
@@ -92,7 +92,7 @@ tiprack_96_high:
- 1.5708 - 1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_96_high/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
tiprack_box: tiprack_box:
@@ -107,7 +107,7 @@ tiprack_box:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: tip/meshes/tip.stl children_mesh: tip/meshes/tip.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tip/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.0045 - 0.0045
- 0.0045 - 0.0045
@@ -123,6 +123,6 @@ tiprack_box:
- 0 - 0
- 0 - 0
- 0 - 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tiprack_box/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0

View File

@@ -11,7 +11,7 @@ bottle_container:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: bottle/meshes/bottle.stl children_mesh: bottle/meshes/bottle.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.04 - 0.04
- 0.04 - 0.04
@@ -27,7 +27,7 @@ bottle_container:
- 0 - 0
- 0 - 0
- 0 - 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/bottle_container/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
tube_container: tube_container:
@@ -43,7 +43,7 @@ tube_container:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: tube/meshes/tube.stl children_mesh: tube/meshes/tube.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.017 - 0.017
- 0.017 - 0.017
@@ -59,6 +59,6 @@ tube_container:
- 0 - 0
- 0 - 0
- 0 - 0
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tube_container/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0

View File

@@ -10,6 +10,6 @@ TransformXYZDeck:
init_param_schema: {} init_param_schema: {}
model: model:
mesh: liquid_transform_xyz mesh: liquid_transform_xyz
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/liquid_transform_xyz/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0

View File

@@ -10,7 +10,7 @@ OTDeck:
init_param_schema: {} init_param_schema: {}
model: model:
mesh: opentrons_liquid_handler mesh: opentrons_liquid_handler
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/opentrons_liquid_handler/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0
hplc_station: hplc_station:
@@ -25,6 +25,6 @@ hplc_station:
init_param_schema: {} init_param_schema: {}
model: model:
mesh: hplc_station mesh: hplc_station
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/devices/hplc_station/macro_device.xacro
type: device type: device
version: 1.0.0 version: 1.0.0

View File

@@ -109,7 +109,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
init_param_schema: {} init_param_schema: {}
model: model:
children_mesh: generic_labware_tube_10_75/meshes/0_base.stl children_mesh: generic_labware_tube_10_75/meshes/0_base.stl
children_mesh_path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro children_mesh_path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/generic_labware_tube_10_75/modal.xacro
children_mesh_tf: children_mesh_tf:
- 0.0018 - 0.0018
- 0.0018 - 0.0018
@@ -125,7 +125,7 @@ nest_96_wellplate_100ul_pcr_full_skirt:
- -1.5708 - -1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
nest_96_wellplate_200ul_flat: nest_96_wellplate_200ul_flat:
@@ -158,7 +158,7 @@ nest_96_wellplate_2ml_deep:
- -1.5708 - -1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
thermoscientificnunc_96_wellplate_1300ul: thermoscientificnunc_96_wellplate_1300ul:

View File

@@ -69,7 +69,7 @@ opentrons_96_filtertiprack_1000ul:
- -1.5708 - -1.5708
- 0 - 0
- 1.5708 - 1.5708
path: https://uni-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro path: https://leap-lab.oss-cn-zhangjiakou.aliyuncs.com/uni-lab/resources/tecan_nested_tip_rack/modal.xacro
type: resource type: resource
version: 1.0.0 version: 1.0.0
opentrons_96_filtertiprack_10ul: opentrons_96_filtertiprack_10ul:

View File

@@ -17,6 +17,7 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance from msgcenterpy.instances.typed_dict_instance import TypedDictMessageInstance
from unilabos.utils.cls_creator import import_class from unilabos.utils.cls_creator import import_class
from unilabos.registry.decorators import Side, DataSource, normalize_enum_value
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -487,10 +488,7 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
} }
side = h.get("side") side = h.get("side")
if side: if side:
if isinstance(side, str) and "." in side: entry["side"] = normalize_enum_value(side, Side) or side
val = side.rsplit(".", 1)[-1]
side = val.lower() if val in ("LEFT", "RIGHT", "TOP", "BOTTOM") else val
entry["side"] = side
label = h.get("label") label = h.get("label")
if label: if label:
entry["label"] = label entry["label"] = label
@@ -499,10 +497,7 @@ def normalize_ast_handles(handles_raw: Any) -> List[Dict[str, Any]]:
entry["data_key"] = data_key entry["data_key"] = data_key
data_source = h.get("data_source") data_source = h.get("data_source")
if data_source: if data_source:
if isinstance(data_source, str) and "." in data_source: entry["data_source"] = normalize_enum_value(data_source, DataSource) or data_source
val = data_source.rsplit(".", 1)[-1]
data_source = val.lower() if val in ("HANDLE", "EXECUTOR") else val
entry["data_source"] = data_source
description = h.get("description") description = h.get("description")
if description: if description:
entry["description"] = description entry["description"] = description
@@ -537,17 +532,12 @@ def normalize_ast_action_handles(handles_raw: Any) -> Dict[str, Any]:
"data_type": h.get("data_type", ""), "data_type": h.get("data_type", ""),
"label": h.get("label", ""), "label": h.get("label", ""),
} }
_FIELD_ENUM_MAP = {"side": Side, "data_source": DataSource}
for opt_key in ("side", "data_key", "data_source", "description", "io_type"): for opt_key in ("side", "data_key", "data_source", "description", "io_type"):
val = h.get(opt_key) val = h.get(opt_key)
if val is not None: if val is not None:
# Only resolve enum-style refs (e.g. DataSource.HANDLE -> handle) for data_source/side if opt_key in _FIELD_ENUM_MAP:
# data_key values like "wells.@flatten", "@this.0@@@plate" must be preserved as-is val = normalize_enum_value(val, _FIELD_ENUM_MAP[opt_key]) or val
if (
isinstance(val, str)
and "." in val
and opt_key not in ("io_type", "data_key")
):
val = val.rsplit(".", 1)[-1].lower()
entry[opt_key] = val entry[opt_key] = val
# io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it) # io_type: only add when explicitly set; do not default output to "sink" (YAML convention omits it)

View File

@@ -1,4 +1,4 @@
from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d from pylabrobot.resources import create_homogeneous_resources, Coordinate, ResourceHolder, create_ordered_items_2d, Container
from unilabos.resources.itemized_carrier import BottleCarrier from unilabos.resources.itemized_carrier import BottleCarrier
from unilabos.resources.bioyond.bottles import ( from unilabos.resources.bioyond.bottles import (
@@ -9,6 +9,28 @@ from unilabos.resources.bioyond.bottles import (
BIOYOND_PolymerStation_Reagent_Bottle, BIOYOND_PolymerStation_Reagent_Bottle,
BIOYOND_PolymerStation_Flask, BIOYOND_PolymerStation_Flask,
) )
def BIOYOND_PolymerStation_Tip(name: str, size_x: float = 8.0, size_y: float = 8.0, size_z: float = 50.0) -> Container:
"""创建单个枪头资源
Args:
name: 枪头名称
size_x: 枪头宽度 (mm)
size_y: 枪头长度 (mm)
size_z: 枪头高度 (mm)
Returns:
Container: 枪头容器
"""
return Container(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category="tip",
model="BIOYOND_PolymerStation_Tip",
)
# 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask,小瓶-Vial # 命名约定:试剂瓶-Bottle烧杯-Beaker烧瓶-Flask,小瓶-Vial
@@ -322,3 +344,88 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
carrier.num_items_z = 1 carrier.num_items_z = 1
carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1") carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1")
return carrier return carrier
def BIOYOND_PolymerStation_TipBox(
name: str,
size_x: float = 127.76, # 枪头盒宽度
size_y: float = 85.48, # 枪头盒长度
size_z: float = 100.0, # 枪头盒高度
barcode: str = None,
) -> BottleCarrier:
"""创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
Args:
name: 枪头盒名称
size_x: 枪头盒宽度 (mm)
size_y: 枪头盒长度 (mm)
size_z: 枪头盒高度 (mm)
barcode: 条形码
Returns:
BottleCarrier: 包含24个枪头孔位的枪头盒载架
布局说明:
- 4行×6列 (A-D, 1-6)
- 枪头孔位间距: 18mm (x方向) × 18mm (y方向)
- 起始位置居中对齐
- 索引顺序: 列优先 (0=A1, 1=B1, 2=C1, 3=D1, 4=A2, ...)
"""
# 枪头孔位参数
num_cols = 6 # 1-6 (x方向)
num_rows = 4 # A-D (y方向)
tip_diameter = 8.0 # 枪头孔位直径
tip_spacing_x = 18.0 # 列间距 (增加到18mm更宽松)
tip_spacing_y = 18.0 # 行间距 (增加到18mm更宽松)
# 计算起始位置 (居中对齐)
total_width = (num_cols - 1) * tip_spacing_x + tip_diameter
total_height = (num_rows - 1) * tip_spacing_y + tip_diameter
start_x = (size_x - total_width) / 2
start_y = (size_y - total_height) / 2
# 使用 create_ordered_items_2d 创建孔位
# create_ordered_items_2d 返回的 key 是数字索引: 0, 1, 2, ...
# 顺序是列优先: 先y后x (即 0=A1, 1=B1, 2=C1, 3=D1, 4=A2, 5=B2, ...)
sites = create_ordered_items_2d(
klass=ResourceHolder,
num_items_x=num_cols,
num_items_y=num_rows,
dx=start_x,
dy=start_y,
dz=5.0,
item_dx=tip_spacing_x,
item_dy=tip_spacing_y,
size_x=tip_diameter,
size_y=tip_diameter,
size_z=50.0, # 枪头深度
)
# 更新 sites 中每个 ResourceHolder 的名称
for k, v in sites.items():
v.name = f"{name}_{v.name}"
# 创建枪头盒载架
# 注意:不设置 category使用默认的 "bottle_carrier",这样前端会显示为完整的矩形载架
tip_box = BottleCarrier(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
sites=sites, # 直接使用数字索引的 sites
model="BIOYOND_PolymerStation_TipBox",
)
# 设置自定义属性
tip_box.barcode = barcode
tip_box.tip_count = 24 # 4行×6列
tip_box.num_items_x = num_cols
tip_box.num_items_y = num_rows
tip_box.num_items_z = 1
# ⭐ 枪头盒不需要放入子资源
# 与其他 carrier 不同,枪头盒在 Bioyond 中是一个整体
# 不需要追踪每个枪头的状态,保持为空的 ResourceHolder 即可
# 这样前端会显示24个空槽位可以用于放置枪头
return tip_box

View File

@@ -116,7 +116,9 @@ def BIOYOND_PolymerStation_TipBox(
size_z: float = 100.0, # 枪头盒高度 size_z: float = 100.0, # 枪头盒高度
barcode: str = None, barcode: str = None,
): ):
"""创建4×6枪头盒 (24个枪头) """创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
注意:此函数已弃用,请使用 bottle_carriers.py 中的版本
Args: Args:
name: 枪头盒名称 name: 枪头盒名称
@@ -126,55 +128,11 @@ def BIOYOND_PolymerStation_TipBox(
barcode: 条形码 barcode: 条形码
Returns: Returns:
TipBoxCarrier: 包含24个枪头孔位的枪头盒 BottleCarrier: 包含24个枪头孔位的枪头盒载架
""" """
from pylabrobot.resources import Container, Coordinate # 重定向到 bottle_carriers.py 中的实现
from unilabos.resources.bioyond.bottle_carriers import BIOYOND_PolymerStation_TipBox as TipBox_Carrier
# 创建枪头盒容器 return TipBox_Carrier(name=name, size_x=size_x, size_y=size_y, size_z=size_z, barcode=barcode)
tip_box = Container(
name=name,
size_x=size_x,
size_y=size_y,
size_z=size_z,
category="tip_rack",
model="BIOYOND_PolymerStation_TipBox_4x6",
)
# 设置自定义属性
tip_box.barcode = barcode
tip_box.tip_count = 24 # 4行×6列
tip_box.num_items_x = 6 # 6列
tip_box.num_items_y = 4 # 4行
# 创建24个枪头孔位 (4行×6列)
# 假设孔位间距为 9mm
tip_spacing_x = 9.0 # 列间距
tip_spacing_y = 9.0 # 行间距
start_x = 14.38 # 第一个孔位的x偏移
start_y = 11.24 # 第一个孔位的y偏移
for row in range(4): # A, B, C, D
for col in range(6): # 1-6
spot_name = f"{chr(65 + row)}{col + 1}" # A1, A2, ..., D6
x = start_x + col * tip_spacing_x
y = start_y + row * tip_spacing_y
# 创建枪头孔位容器
tip_spot = Container(
name=spot_name,
size_x=8.0, # 单个枪头孔位大小
size_y=8.0,
size_z=size_z - 10.0, # 略低于盒子高度
category="tip_spot",
)
# 添加到枪头盒
tip_box.assign_child_resource(
tip_spot,
location=Coordinate(x=x, y=y, z=0)
)
return tip_box
def BIOYOND_PolymerStation_Flask( def BIOYOND_PolymerStation_Flask(

View File

@@ -797,9 +797,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
bottle = plr_material[number] = initialize_resource( bottle = plr_material[number] = initialize_resource(
{"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR {"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR
) )
bottle.tracker.liquids = [ # 只有具有 tracker 属性的容器才设置液体信息(如 Bottle, Well
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0) # ResourceHolder 等不支持液体追踪的容器跳过
] if hasattr(bottle, "tracker"):
bottle.tracker.liquids = [
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
]
bottle.code = detail.get("code", "") bottle.code = detail.get("code", "")
logger.debug(f" └─ [子物料] {detail['name']}{plr_material.name}[{number}] (类型:{typeName})") logger.debug(f" └─ [子物料] {detail['name']}{plr_material.name}[{number}] (类型:{typeName})")
else: else:
@@ -808,9 +811,11 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
# 只对有 capacity 属性的容器(液体容器)处理液体追踪 # 只对有 capacity 属性的容器(液体容器)处理液体追踪
if hasattr(plr_material, 'capacity'): if hasattr(plr_material, 'capacity'):
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
bottle.tracker.liquids = [ # 确保 bottletracker 属性才设置液体信息
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0) if hasattr(bottle, "tracker"):
] bottle.tracker.liquids = [
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
]
plr_materials.append(plr_material) plr_materials.append(plr_material)
@@ -839,24 +844,29 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
wh_name = loc.get("whName") wh_name = loc.get("whName")
logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})") logger.debug(f"[物料位置] {unique_name} 尝试放置到 warehouse: {wh_name} (Bioyond坐标: x={loc.get('x')}, y={loc.get('y')}, z={loc.get('z')})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
# 必须在warehouse映射之前先获取坐标以便后续调整
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右" # 特殊处理: Bioyond的"堆栈1"需要映射到"堆栈1左"或"堆栈1右"
# 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧 # 根据列号(y)判断: 1-4映射到左侧, 5-8映射到右侧
if wh_name == "堆栈1": if wh_name == "堆栈1":
x_val = loc.get("x", 1) if 1 <= y <= 4:
if 1 <= x_val <= 4:
wh_name = "堆栈1左" wh_name = "堆栈1左"
elif 5 <= x_val <= 8: elif 5 <= y <= 8:
wh_name = "堆栈1右" wh_name = "堆栈1右"
y = y - 4 # 调整列号: 5-8映射到1-4
else: else:
logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围无法映射到堆栈1左或堆栈1右") logger.warning(f"物料 {material['name']} 的列号 y={y} 超出范围无法映射到堆栈1左或堆栈1右")
continue continue
# 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射 # 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射
if wh_name == "站内Tip盒堆栈": if wh_name == "站内Tip盒堆栈":
y_val = loc.get("y", 1) if y == 1:
if y_val == 1:
wh_name = "站内Tip盒堆栈(右)" wh_name = "站内Tip盒堆栈(右)"
elif y_val in [2, 3]: elif y in [2, 3]:
wh_name = "站内Tip盒堆栈(左)" wh_name = "站内Tip盒堆栈(左)"
y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列 y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列
@@ -864,15 +874,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
warehouse = deck.warehouses[wh_name] warehouse = deck.warehouses[wh_name]
logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})") logger.debug(f"[Warehouse匹配] 找到warehouse: {wh_name} (容量: {warehouse.capacity}, 行×列: {warehouse.num_items_x}×{warehouse.num_items_y})")
# Bioyond坐标映射 (重要!): x→行(1=A,2=B...), y→列(1=01,2=02...), z→层(通常=1)
x = loc.get("x", 1) # 行号 (1-based: 1=A, 2=B, 3=C, 4=D)
y = loc.get("y", 1) # 列号 (1-based: 1=01, 2=02, 3=03...)
z = loc.get("z", 1) # 层号 (1-based, 通常为1)
# 如果是右侧堆栈,需要调整列号 (5→1, 6→2, 7→3, 8→4)
if wh_name == "堆栈1右":
y = y - 4 # 将5-8映射到1-4
# 特殊处理竖向warehouse站内试剂存放堆栈、测量小瓶仓库 # 特殊处理竖向warehouse站内试剂存放堆栈、测量小瓶仓库
# 这些warehouse使用 vertical-col-major 布局 # 这些warehouse使用 vertical-col-major 布局
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]: if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
@@ -1033,7 +1034,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})") logger.debug(f"🔍 [PLR→Bioyond] detail转换: {bottle.name} → PLR(x={site['x']},y={site['y']},id={site.get('identifier','?')}) → Bioyond(x={bioyond_x},y={bioyond_y})")
# 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称去除PLR系统添加的后缀 # 🔥 提取物料名称:从 tracker.liquids 中获取第一个液体的名称去除PLR系统添加的后缀
# tracker.liquids 格式: [(物料名称, 数量), ...] # tracker.liquids 格式: [(物料名称, 数量, 单位), ...]
material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶" material_name = bottle_type_info[0] # 默认使用类型名称(如"样品瓶"
if hasattr(bottle, "tracker") and bottle.tracker.liquids: if hasattr(bottle, "tracker") and bottle.tracker.liquids:
# 如果有液体,使用液体的名称 # 如果有液体,使用液体的名称
@@ -1051,7 +1052,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
"typeId": bottle_type_info[1], "typeId": bottle_type_info[1],
"code": bottle.code if hasattr(bottle, "code") else "", "code": bottle.code if hasattr(bottle, "code") else "",
"name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶" "name": material_name, # 使用物料名称(如"9090"),而不是类型名称("样品瓶"
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"x": bioyond_x, "x": bioyond_x,
"y": bioyond_y, "y": bioyond_y,
"z": 1, "z": 1,
@@ -1124,7 +1125,7 @@ def resource_plr_to_bioyond(plr_resources: list[ResourcePLR], type_mapping: dict
"barCode": "", "barCode": "",
"name": material_name, # 使用物料名称而不是资源名称 "name": material_name, # 使用物料名称而不是资源名称
"unit": default_unit, # 使用配置的单位或默认单位 "unit": default_unit, # 使用配置的单位或默认单位
"quantity": sum(qty for _, qty in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0, "quantity": sum(qty for _, qty, *_ in bottle.tracker.liquids) if hasattr(bottle, "tracker") else 0,
"Parameters": parameters_json # API 实际要求的字段(必需) "Parameters": parameters_json # API 实际要求的字段(必需)
} }

View File

@@ -18,3 +18,9 @@ def register():
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend from unilabos.devices.liquid_handling.laiyu.backend.laiyu_v_backend import UniLiquidHandlerLaiyuBackend
# noinspection PyUnresolvedReferences
from unilabos.resources.bioyond.decks import (
BIOYOND_PolymerReactionStation_Deck,
BIOYOND_PolymerPreparationStation_Deck,
BIOYOND_YB_Deck,
)

View File

@@ -423,6 +423,7 @@ class ResourceTreeSet(object):
"deck": "deck", "deck": "deck",
"tip_rack": "tip_rack", "tip_rack": "tip_rack",
"tip_spot": "tip_spot", "tip_spot": "tip_spot",
"tip": "tip", # 添加 tip 类型支持
"tube": "tube", "tube": "tube",
"bottle_carrier": "bottle_carrier", "bottle_carrier": "bottle_carrier",
"material_hole": "material_hole", "material_hole": "material_hole",

View File

@@ -1,4 +1,5 @@
import json import json
import os
# from nt import device_encoding # from nt import device_encoding
import threading import threading
@@ -61,7 +62,7 @@ def main(
rclpy.init(args=rclpy_init_args) rclpy.init(args=rclpy_init_args)
else: else:
logger.info("[ROS] rclpy already initialized, reusing context") logger.info("[ROS] rclpy already initialized, reusing context")
executor = rclpy.__executor = MultiThreadedExecutor() executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
# 创建主机节点 # 创建主机节点
host_node = HostNode( host_node = HostNode(
"host_node", "host_node",
@@ -122,7 +123,7 @@ def slave(
rclpy.init(args=rclpy_init_args) rclpy.init(args=rclpy_init_args)
executor = rclpy.__executor executor = rclpy.__executor
if not executor: if not executor:
executor = rclpy.__executor = MultiThreadedExecutor() executor = rclpy.__executor = MultiThreadedExecutor(num_threads=max(os.cpu_count() * 4, 48))
# 1.5 启动 executor 线程 # 1.5 启动 executor 线程
thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread") thread = threading.Thread(target=executor.spin, daemon=True, name="slave_executor_thread")

View File

@@ -4,6 +4,8 @@ import json
import threading import threading
import time import time
import traceback import traceback
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
from typing import ( from typing import (
get_type_hints, get_type_hints,
TypeVar, TypeVar,
@@ -78,6 +80,67 @@ if TYPE_CHECKING:
T = TypeVar("T") T = TypeVar("T")
class RclpyAsyncMutex:
"""rclpy executor 兼容的异步互斥锁
通过 executor.create_task 唤醒等待者,避免 timer 的 InvalidHandle 问题。
"""
def __init__(self, name: str = ""):
self._lock = threading.Lock()
self._acquired = False
self._queue: List[Future] = []
self._name = name
self._holder: Optional[str] = None
async def acquire(self, node: "BaseROS2DeviceNode", tag: str = ""):
"""获取锁。如果已被占用,则异步等待直到锁释放。"""
# t0 = time.time()
with self._lock:
# qlen = len(self._queue)
if not self._acquired:
self._acquired = True
self._holder = tag
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 获取锁 tag={tag} (无等待, queue=0)"
# )
return
waiter = Future()
self._queue.append(waiter)
# node.lab_logger().info(
# f"[Mutex:{self._name}] 等待锁 tag={tag} "
# f"(holder={self._holder}, queue={qlen + 1})"
# )
await waiter
# wait_ms = (time.time() - t0) * 1000
self._holder = tag
# node.lab_logger().info(
# f"[Mutex:{self._name}] 获取锁 tag={tag} (等了 {wait_ms:.0f}ms)"
# )
def release(self, node: "BaseROS2DeviceNode"):
"""释放锁,通过 executor task 唤醒下一个等待者。"""
with self._lock:
# old_holder = self._holder
if self._queue:
next_waiter = self._queue.pop(0)
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 唤醒下一个 (剩余 queue={len(self._queue)})"
# )
async def _wake():
if not next_waiter.done():
next_waiter.set_result(None)
rclpy.get_global_executor().create_task(_wake())
else:
self._acquired = False
self._holder = None
# node.lab_logger().debug(
# f"[Mutex:{self._name}] 释放锁 holder={old_holder} → 空闲"
# )
# 在线设备注册表 # 在线设备注册表
registered_devices: Dict[str, "DeviceInfoType"] = {} registered_devices: Dict[str, "DeviceInfoType"] = {}
@@ -355,6 +418,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}" max_workers=max(len(action_value_mappings), 1), thread_name_prefix=f"ROSDevice{self.device_id}"
) )
self._append_resource_lock = RclpyAsyncMutex(name=f"AR:{device_id}")
# 创建资源管理客户端 # 创建资源管理客户端
self._resource_clients: Dict[str, Client] = { self._resource_clients: Dict[str, Client] = {
"resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group), "resource_add": self.create_client(ResourceAdd, "/resources/add", callback_group=self.callback_group),
@@ -378,15 +443,40 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return res return res
async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response): async def append_resource(req: SerialCommand_Request, res: SerialCommand_Response):
_cmd = _fast_loads(req.command)
_res_name = _cmd.get("resource", [{}])
_res_name = (_res_name[0].get("id", "?") if isinstance(_res_name, list) and _res_name
else _res_name.get("id", "?") if isinstance(_res_name, dict) else "?")
_ar_tag = f"{_res_name}"
# _t_enter = time.time()
# self.lab_logger().info(f"[AR:{_ar_tag}] 进入 append_resource")
await self._append_resource_lock.acquire(self, tag=_ar_tag)
# _t_locked = time.time()
try:
return await _append_resource_inner(req, res, _ar_tag)
# _t_done = time.time()
# self.lab_logger().info(
# f"[AR:{_ar_tag}] 完成 "
# f"等锁={(_t_locked - _t_enter) * 1000:.0f}ms "
# f"执行={(_t_done - _t_locked) * 1000:.0f}ms "
# f"总计={(_t_done - _t_enter) * 1000:.0f}ms"
# )
except Exception as _ex:
self.lab_logger().error(f"[AR:{_ar_tag}] 异常: {_ex}")
raise
finally:
self._append_resource_lock.release(self)
async def _append_resource_inner(req: SerialCommand_Request, res: SerialCommand_Response, _ar_tag: str = ""):
from pylabrobot.resources.deck import Deck from pylabrobot.resources.deck import Deck
from pylabrobot.resources import Coordinate from pylabrobot.resources import Coordinate
from pylabrobot.resources import Plate from pylabrobot.resources import Plate
# 物料传输到对应的node节点 # _t0 = time.time()
client = self._resource_clients["c2s_update_resource_tree"] client = self._resource_clients["c2s_update_resource_tree"]
request = SerialCommand.Request() request = SerialCommand.Request()
request2 = SerialCommand.Request() request2 = SerialCommand.Request()
command_json = json.loads(req.command) command_json = _fast_loads(req.command)
namespace = command_json["namespace"] namespace = command_json["namespace"]
bind_parent_id = command_json["bind_parent_id"] bind_parent_id = command_json["bind_parent_id"]
edge_device_id = command_json["edge_device_id"] edge_device_id = command_json["edge_device_id"]
@@ -439,7 +529,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}" f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
) )
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
request.command = json.dumps( # _t1 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 准备完成 PLR转换+序列化 {((_t1 - _t0) * 1000):.0f}ms, 发送首次上传..."
# )
request.command = _fast_dumps_str(
{ {
"action": "add", "action": "add",
"data": { "data": {
@@ -450,7 +544,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
} }
) )
tree_response: SerialCommand.Response = await client.call_async(request) tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response) # _t2 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 首次上传完成 {((_t2 - _t1) * 1000):.0f}ms"
# )
uuid_maps = _fast_loads(tree_response.response)
plr_instances = rts.to_plr_resources() plr_instances = rts.to_plr_resources()
for plr_instance in plr_instances: for plr_instance in plr_instances:
self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps) self.resource_tracker.loop_update_uuid(plr_instance, uuid_maps)
@@ -486,18 +584,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if len(rts.root_nodes) == 1 and parent_resource is not None: if len(rts.root_nodes) == 1 and parent_resource is not None:
plr_instance = plr_instances[0] plr_instance = plr_instances[0]
if isinstance(plr_instance, Plate): if isinstance(plr_instance, Plate):
empty_liquid_info_in: List[Tuple[Optional[str], float]] = [(None, 0)] * plr_instance.num_items
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1: if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT) ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT) LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
self.lab_logger().warning( self.lab_logger().warning(
f"增加液体资源时数量为1自动补全为 {len(LIQUID_INPUT_SLOT)}" f"增加液体资源时数量为1自动补全为 {len(LIQUID_INPUT_SLOT)}"
) )
for liquid_type, liquid_volume, liquid_input_slot in zip(
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
):
empty_liquid_info_in[liquid_input_slot] = (liquid_type, liquid_volume)
plr_instance.set_well_liquids(empty_liquid_info_in)
try: try:
# noinspection PyProtectedMember # noinspection PyProtectedMember
keys = list(plr_instance._ordering.keys()) keys = list(plr_instance._ordering.keys())
@@ -511,6 +603,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
input_wells = [] input_wells = []
for r in LIQUID_INPUT_SLOT: for r in LIQUID_INPUT_SLOT:
input_wells.append(plr_instance.children[r]) input_wells.append(plr_instance.children[r])
for input_well, liquid_type, liquid_volume, liquid_input_slot in zip(
input_wells, ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
):
input_well.set_liquids([(liquid_type, liquid_volume, "ul")])
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources( final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
input_wells input_wells
).dump() ).dump()
@@ -529,12 +625,13 @@ class BaseROS2DeviceNode(Node, Generic[T]):
Coordinate(location["x"], location["y"], location["z"]), Coordinate(location["x"], location["y"], location["z"]),
**other_calling_param, **other_calling_param,
) )
# 调整了液体以及Deck之后要重新Assign
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
# _t3 = time.time()
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource]) rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
# _n_parent = len(rts_with_parent.all_nodes)
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None: if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
request.command = json.dumps( request.command = _fast_dumps_str(
{ {
"action": "add", "action": "add",
"data": { "data": {
@@ -544,11 +641,18 @@ class BaseROS2DeviceNode(Node, Generic[T]):
}, },
} }
) )
# _t4 = time.time()
# self.lab_logger().debug(
# f"[AR:{_ar_tag}] 二次上传序列化 {_n_parent}节点 {((_t4 - _t3) * 1000):.0f}ms, 发送中..."
# )
tree_response: SerialCommand.Response = await client.call_async(request) tree_response: SerialCommand.Response = await client.call_async(request)
uuid_maps = json.loads(tree_response.response) # _t5 = time.time()
uuid_maps = _fast_loads(tree_response.response)
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps) self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
self._lab_logger.info(f"Resource tree added. UUID mapping: {len(uuid_maps)} nodes") # self._lab_logger.info(
# 这里created_resources不包含parent_resource # f"[AR:{_ar_tag}] 二次上传完成 HTTP={(_t5 - _t4) * 1000:.0f}ms "
# f"UUID映射={len(uuid_maps)}节点 总执行={(_t5 - _t0) * 1000:.0f}ms"
# )
# 发送给ResourceMeshManager # 发送给ResourceMeshManager
action_client = ActionClient( action_client = ActionClient(
self, self,
@@ -685,7 +789,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
) )
# 发送请求并等待响应 # 发送请求并等待响应
response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r) response: SerialCommand_Response = await self._resource_clients["resource_get"].call_async(r)
if not response.response:
raise ValueError(f"查询资源 {resource_id} 失败:服务端返回空响应")
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
if not raw_data:
raise ValueError(f"查询资源 {resource_id} 失败:返回数据为空")
# 转换为 PLR 资源 # 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
@@ -1134,7 +1242,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
if uid is None: if uid is None:
raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性无法转运") raise ValueError(f"目标物料{target_resource}没有unilabos_uuid属性无法转运")
target_uids.append(uid) target_uids.append(uid)
srv_address = f"/srv{target_device_id}/s2c_resource_tree" _ns = target_device_id if target_device_id.startswith("/devices/") else f"/devices/{target_device_id.lstrip('/')}"
srv_address = f"/srv{_ns}/s2c_resource_tree"
sclient = self.create_client(SerialCommand, srv_address) sclient = self.create_client(SerialCommand, srv_address)
# 等待服务可用(设置超时) # 等待服务可用(设置超时)
if not sclient.wait_for_service(timeout_sec=5.0): if not sclient.wait_for_service(timeout_sec=5.0):
@@ -1184,7 +1293,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return False return False
time.sleep(0.05) time.sleep(0.05)
self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}") self.lab_logger().info(f"资源本地增加到{target_device_id}结果: {response.response}")
return None return "转运完成"
def register_device(self): def register_device(self):
"""向注册表中注册设备信息""" """向注册表中注册设备信息"""
@@ -1256,9 +1365,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
return self._lab_logger return self._lab_logger
def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0): def create_ros_publisher(self, attr_name, msg_type, initial_period=5.0):
"""创建ROS发布者,仅当方法/属性有 @topic_config 装饰器时才创建""" """创建ROS发布者。已在 status_types 中声明的属性直接创建;@topic_config 用于覆盖默认参数"""
# 检测 @topic_config 装饰器配置 topic_cfg = {}
topic_config = {}
driver_class = type(self.driver_instance) driver_class = type(self.driver_instance)
# 区分 @property 和普通方法两种情况 # 区分 @property 和普通方法两种情况
@@ -1267,23 +1375,17 @@ class BaseROS2DeviceNode(Node, Generic[T]):
) )
if is_prop: if is_prop:
# @property: 检测 fget 上的 @topic_config
class_attr = getattr(driver_class, attr_name) class_attr = getattr(driver_class, attr_name)
if class_attr.fget is not None: if class_attr.fget is not None:
topic_config = get_topic_config(class_attr.fget) topic_cfg = get_topic_config(class_attr.fget)
else: else:
# 普通方法: 直接检测 attr_name 方法上的 @topic_config
if hasattr(self.driver_instance, attr_name): if hasattr(self.driver_instance, attr_name):
method = getattr(self.driver_instance, attr_name) method = getattr(self.driver_instance, attr_name)
if callable(method): if callable(method):
topic_config = get_topic_config(method) topic_cfg = get_topic_config(method)
# 没有 @topic_config 装饰器则跳过发布
if not topic_config:
return
# 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name # 发布名称优先级: @topic_config(name=...) > get_ 前缀去除 > attr_name
cfg_name = topic_config.get("name") cfg_name = topic_cfg.get("name")
if cfg_name: if cfg_name:
publish_name = cfg_name publish_name = cfg_name
elif attr_name.startswith("get_"): elif attr_name.startswith("get_"):
@@ -1291,10 +1393,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
else: else:
publish_name = attr_name publish_name = attr_name
# 使用装饰器配置或默认值 # @topic_config 参数覆盖默认值
cfg_period = topic_config.get("period") cfg_period = topic_cfg.get("period")
cfg_print = topic_config.get("print_publish") cfg_print = topic_cfg.get("print_publish")
cfg_qos = topic_config.get("qos") cfg_qos = topic_cfg.get("qos")
period: float = cfg_period if cfg_period is not None else initial_period period: float = cfg_period if cfg_period is not None else initial_period
print_publish: bool = cfg_print if cfg_print is not None else self._print_publish print_publish: bool = cfg_print if cfg_print is not None else self._print_publish
qos: int = cfg_qos if cfg_qos is not None else 10 qos: int = cfg_qos if cfg_qos is not None else 10
@@ -1576,37 +1678,75 @@ class BaseROS2DeviceNode(Node, Generic[T]):
feedback_msg_types = action_type.Feedback.get_fields_and_field_types() feedback_msg_types = action_type.Feedback.get_fields_and_field_types()
result_msg_types = action_type.Result.get_fields_and_field_types() result_msg_types = action_type.Result.get_fields_and_field_types()
while future is not None and not future.done(): # 低频 feedback timer10s不阻塞完成检测
if goal_handle.is_cancel_requested: _feedback_timer = None
self.lab_logger().info(f"取消动作: {action_name}")
future.cancel() # 尝试取消线程池中的任务
goal_handle.canceled()
return action_type.Result()
self._time_spent = time.time() - time_start def _publish_feedback():
self._time_remaining = time_overall - self._time_spent if future is not None and not future.done():
self._time_spent = time.time() - time_start
self._time_remaining = time_overall - self._time_spent
feedback_values = {}
for msg_name, attr_name in action_value_mapping["feedback"].items():
if hasattr(self.driver_instance, f"get_{attr_name}"):
method = getattr(self.driver_instance, f"get_{attr_name}")
if not asyncio.iscoroutinefunction(method):
feedback_values[msg_name] = method()
elif hasattr(self.driver_instance, attr_name):
feedback_values[msg_name] = getattr(self.driver_instance, attr_name)
if self._print_publish:
self.lab_logger().info(f"反馈: {feedback_values}")
feedback_msg = convert_to_ros_msg_with_mapping(
ros_msg_type=action_type.Feedback(),
obj=feedback_values,
value_mapping=action_value_mapping["feedback"],
)
goal_handle.publish_feedback(feedback_msg)
# 发布反馈 if action_value_mapping.get("feedback"):
feedback_values = {} _fb_interval = action_value_mapping.get("feedback_interval", 0.5)
for msg_name, attr_name in action_value_mapping["feedback"].items(): _feedback_timer = self.create_timer(
if hasattr(self.driver_instance, f"get_{attr_name}"): _fb_interval, _publish_feedback, callback_group=self.callback_group
method = getattr(self.driver_instance, f"get_{attr_name}")
if not asyncio.iscoroutinefunction(method):
feedback_values[msg_name] = method()
elif hasattr(self.driver_instance, attr_name):
feedback_values[msg_name] = getattr(self.driver_instance, attr_name)
if self._print_publish:
self.lab_logger().info(f"反馈: {feedback_values}")
feedback_msg = convert_to_ros_msg_with_mapping(
ros_msg_type=action_type.Feedback(),
obj=feedback_values,
value_mapping=action_value_mapping["feedback"],
) )
goal_handle.publish_feedback(feedback_msg) # 等待 action 完成
time.sleep(0.5) if future is not None:
if isinstance(future, Task):
# rclpy Task直接 await完成瞬间唤醒
try:
_raw_result = await future
except Exception as e:
_raw_result = e
else:
# concurrent.futures.Future同步 action用 rclpy 兼容的轮询
_poll_future = Future()
def _on_sync_done(fut):
if not _poll_future.done():
_poll_future.set_result(None)
future.add_done_callback(_on_sync_done)
await _poll_future
try:
_raw_result = future.result()
except Exception as e:
_raw_result = e
# 确保 execution_error/success 被正确设置(不依赖 done callback 时序)
if isinstance(_raw_result, BaseException):
if not execution_error:
execution_error = traceback.format_exception(
type(_raw_result), _raw_result, _raw_result.__traceback__
)
execution_error = "".join(execution_error)
execution_success = False
action_return_value = _raw_result
elif not execution_error:
execution_success = True
action_return_value = _raw_result
# 清理 feedback timer
if _feedback_timer is not None:
_feedback_timer.cancel()
if future is not None and future.cancelled(): if future is not None and future.cancelled():
self.lab_logger().info(f"动作 {action_name} 已取消") self.lab_logger().info(f"动作 {action_name} 已取消")
@@ -1615,8 +1755,12 @@ class BaseROS2DeviceNode(Node, Generic[T]):
# self.lab_logger().info(f"动作执行完成: {action_name}") # self.lab_logger().info(f"动作执行完成: {action_name}")
del future del future
# 执行失败时跳过物料状态更新
if execution_error:
execution_success = False
# 向Host更新物料当前状态 # 向Host更新物料当前状态
if action_name not in ["create_resource_detailed", "create_resource"]: if not execution_error and action_name not in ["create_resource_detailed", "create_resource"]:
for k, v in goal.get_fields_and_field_types().items(): for k, v in goal.get_fields_and_field_types().items():
if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]: if v not in ["unilabos_msgs/Resource", "sequence<unilabos_msgs/Resource>"]:
continue continue
@@ -1672,7 +1816,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
for attr_name in result_msg_types.keys(): for attr_name in result_msg_types.keys():
if attr_name in ["success", "reached_goal"]: if attr_name in ["success", "reached_goal"]:
setattr(result_msg, attr_name, True) setattr(result_msg, attr_name, execution_success)
elif attr_name == "return_info": elif attr_name == "return_info":
setattr( setattr(
result_msg, result_msg,
@@ -1778,7 +1922,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise ValueError("至少需要提供一个 UUID") raise ValueError("至少需要提供一个 UUID")
uuids_list = list(uuids) uuids_list = list(uuids)
future = self._resource_clients["c2s_update_resource_tree"].call_async( future: Future = self._resource_clients["c2s_update_resource_tree"].call_async(
SerialCommand.Request( SerialCommand.Request(
command=json.dumps( command=json.dumps(
{ {
@@ -1804,6 +1948,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
raise Exception(f"资源查询返回空结果: {uuids_list}") raise Exception(f"资源查询返回空结果: {uuids_list}")
raw_data = json.loads(response.response) raw_data = json.loads(response.response)
if not raw_data:
raise Exception(f"资源原始查询返回空结果: {raw_data}")
# 转换为 PLR 资源 # 转换为 PLR 资源
tree_set = ResourceTreeSet.from_raw_dict_list(raw_data) tree_set = ResourceTreeSet.from_raw_dict_list(raw_data)
@@ -1825,10 +1971,15 @@ class BaseROS2DeviceNode(Node, Generic[T]):
mapped_plr_resources = [] mapped_plr_resources = []
for uuid in uuids_list: for uuid in uuids_list:
found = None
for plr_resource in figured_resources: for plr_resource in figured_resources:
r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid) r = self.resource_tracker.loop_find_with_uuid(plr_resource, uuid)
mapped_plr_resources.append(r) if r is not None:
break found = r
break
if found is None:
raise Exception(f"未能在已解析的资源树中找到 uuid={uuid} 对应的资源")
mapped_plr_resources.append(found)
return mapped_plr_resources return mapped_plr_resources
@@ -1921,16 +2072,27 @@ class BaseROS2DeviceNode(Node, Generic[T]):
f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}" f"执行动作时JSON缺少function_name或function_args: {ex}\n原JSON: {string}\n{traceback.format_exc()}"
) )
async def _convert_resource_async(self, resource_data: Dict[str, Any]): async def _convert_resource_async(self, resource_data: "ResourceDictType"):
"""异步转换资源数据为实例""" """异步转换 ResourceDictType 为 PLR 实例,优先用 uuid 查询"""
# 使用封装的get_resource_with_dir方法获取PLR资源 unilabos_uuid = resource_data.get("uuid")
plr_resource = await self.get_resource_with_dir(resource_ids=resource_data["id"], with_children=True)
if unilabos_uuid:
resource_tree = await self.get_resource([unilabos_uuid], with_children=True)
plr_resources = resource_tree.to_plr_resources()
if plr_resources:
plr_resource = plr_resources[0]
else:
raise ValueError(f"通过 uuid={unilabos_uuid} 查询资源为空")
else:
res_id = resource_data.get("id") or resource_data.get("name", "")
if not res_id:
raise ValueError(f"资源数据缺少 uuid 和 id: {list(resource_data.keys())}")
plr_resource = await self.get_resource_with_dir(resource_id=res_id, with_children=True)
# 通过资源跟踪器获取本地实例 # 通过资源跟踪器获取本地实例
res = self.resource_tracker.figure_resource(plr_resource, try_mode=True) res = self.resource_tracker.figure_resource(plr_resource, try_mode=True)
if len(res) == 0: if len(res) == 0:
# todo: 后续通过decoration来区分减少warning self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data.get('id', '?')},返回新建实例")
self.lab_logger().warning(f"资源转换未能索引到实例: {resource_data},返回新建实例")
return plr_resource return plr_resource
elif len(res) == 1: elif len(res) == 1:
return res[0] return res[0]

View File

@@ -4,6 +4,8 @@ import threading
import time import time
import traceback import traceback
import uuid import uuid
from unilabos.utils.tools import fast_dumps_str as _fast_dumps_str, fast_loads as _fast_loads
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
@@ -24,7 +26,7 @@ from unilabos_msgs.srv import (
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
from unique_identifier_msgs.msg import UUID from unique_identifier_msgs.msg import UUID
from unilabos.registry.decorators import device from unilabos.registry.decorators import device, action, NodeType
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
from unilabos.registry.registry import lab_registry from unilabos.registry.registry import lab_registry
from unilabos.resources.container import RegularContainer from unilabos.resources.container import RegularContainer
@@ -313,7 +315,9 @@ class HostNode(BaseROS2DeviceNode):
callback_group=self.callback_group, callback_group=self.callback_group,
), ),
} # 用来存储多个ActionClient实例 } # 用来存储多个ActionClient实例
self._action_value_mappings: Dict[str, Dict] = {} # device_id -> action_value_mappings(本地+远程设备统一存储) self._action_value_mappings: Dict[str, Dict] = {
device_id: self._action_value_mappings
} # device_id -> action_value_mappings(本地+远程设备统一存储)
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings) self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态 self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备 self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
@@ -616,22 +620,17 @@ class HostNode(BaseROS2DeviceNode):
} }
) )
] ]
response: List[str] = await self.create_resource_detailed( response: List[str] = await self.create_resource_detailed(
resources, device_ids, bind_parent_id, bind_location, other_calling_param resources, device_ids, bind_parent_id, bind_location, other_calling_param
) )
try: assert len(response) == 1, "Create Resource应当只返回一个结果"
assert len(response) == 1, "Create Resource应当只返回一个结果" for i in response:
for i in response: res = json.loads(i)
res = json.loads(i) if "suc" in res and not res["suc"]:
if "suc" in res: raise ValueError(res.get("error", "未知错误"))
raise ValueError(res.get("error")) return res
return res raise ValueError(f"创建资源时失败!响应为空")
except Exception as ex:
pass
_n = "\n"
raise ValueError(f"创建资源时失败!\n{_n.join(response)}")
def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None: def initialize_device(self, device_id: str, device_config: ResourceDictInstance) -> None:
""" """
@@ -1166,7 +1165,7 @@ class HostNode(BaseROS2DeviceNode):
else: else:
physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {})) physical_setup_graph.nodes[resource_dict["id"]]["data"].update(resource_dict.get("data", {}))
response.response = json.dumps(uuid_mapping) if success else "FAILED" response.response = _fast_dumps_str(uuid_mapping) if success else "FAILED"
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}") self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK async def _resource_tree_action_get_callback(self, data: dict, response: SerialCommand_Response): # OK
@@ -1176,6 +1175,7 @@ class HostNode(BaseROS2DeviceNode):
resource_response = http_client.resource_tree_get(uuid_list, with_children) resource_response = http_client.resource_tree_get(uuid_list, with_children)
response.response = json.dumps(resource_response) response.response = json.dumps(resource_response)
self.lab_logger().trace(f"[Host Node-Resource] Resource tree get request callback {response.response}")
async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response): async def _resource_tree_action_remove_callback(self, data: dict, response: SerialCommand_Response):
""" """
@@ -1228,9 +1228,26 @@ class HostNode(BaseROS2DeviceNode):
""" """
try: try:
# 解析请求数据 # 解析请求数据
data = json.loads(request.command) data = _fast_loads(request.command)
action = data["action"] action = data["action"]
self.lab_logger().info(f"[Host Node-Resource] Resource tree {action} request received") inner = data.get("data", {})
if action == "add":
mount_uuid = inner.get("mount_uuid", "?")[:8] if isinstance(inner, dict) else "?"
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
source = f"mount={mount_uuid}.. nodes≈{node_count}"
elif action in ("get", "remove"):
uid_list = inner.get("data", inner) if isinstance(inner, dict) else inner
source = f"uuids={len(uid_list) if isinstance(uid_list, list) else '?'}"
elif action == "update":
tree_data = inner.get("data", []) if isinstance(inner, dict) else inner
node_count = len(tree_data) if isinstance(tree_data, list) else "?"
source = f"nodes≈{node_count}"
else:
source = ""
self.lab_logger().info(
f"[Host Node-Resource] Resource tree {action} request received ({source})"
)
data = data["data"] data = data["data"]
if action == "add": if action == "add":
await self._resource_tree_action_add_callback(data, response) await self._resource_tree_action_add_callback(data, response)
@@ -1621,6 +1638,19 @@ class HostNode(BaseROS2DeviceNode):
} }
return res return res
@action(always_free=True, node_type=NodeType.MANUAL_CONFIRM, placeholder_keys={
"assignee_user_ids": "unilabos_manual_confirm"
}, goal_default={
"timeout_seconds": 3600,
"assignee_user_ids": []
})
def manual_confirm(self, timeout_seconds: int, assignee_user_ids: list[str], **kwargs) -> dict:
"""
timeout_seconds: 超时时间默认3600秒
修改的结果无效,是只读的
"""
return kwargs
def test_resource( def test_resource(
self, self,
sample_uuids: SampleUUIDsType, sample_uuids: SampleUUIDsType,

View File

@@ -22,6 +22,447 @@
"arm_state": "idle", "arm_state": "idle",
"message": "工作台就绪" "message": "工作台就绪"
} }
},
{
"id": "PRCXI",
"name": "PRCXI",
"type": "device",
"class": "liquid_handler.prcxi",
"parent": "",
"pose": {
"size": {
"width": 562,
"height": 394,
"depth": 0
}
},
"config": {
"axis": "Left",
"deck": {
"_resource_type": "unilabos.devices.liquid_handling.prcxi.prcxi:PRCXI9300Deck",
"_resource_child_name": "PRCXI_Deck"
},
"host": "10.20.30.184",
"port": 9999,
"debug": true,
"setup": true,
"is_9320": true,
"timeout": 10,
"matrix_id": "5de524d0-3f95-406c-86dd-f83626ebc7cb",
"simulator": true,
"channel_num": 2
},
"data": {
"reset_ok": true
},
"schema": {},
"description": "",
"model": null,
"position": {
"x": 0,
"y": 240,
"z": 0
}
},
{
"id": "PRCXI_Deck",
"name": "PRCXI_Deck",
"children": [],
"parent": "PRCXI",
"type": "deck",
"class": "",
"position": {
"x": 10,
"y": 10,
"z": 0
},
"config": {
"type": "PRCXI9300Deck",
"size_x": 542,
"size_y": 374,
"size_z": 0,
"rotation": {
"x": 0,
"y": 0,
"z": 0,
"type": "Rotation"
},
"category": "deck",
"barcode": null,
"preferred_pickup_location": null,
"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": [
"container",
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T2",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T3",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T4",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 0,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T5",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T6",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T7",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T8",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 96,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T9",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T10",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T11",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T12",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 192,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T13",
"visible": true,
"occupied_by": null,
"position": {
"x": 0,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T14",
"visible": true,
"occupied_by": null,
"position": {
"x": 138,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T15",
"visible": true,
"occupied_by": null,
"position": {
"x": 276,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
},
{
"label": "T16",
"visible": true,
"occupied_by": null,
"position": {
"x": 414,
"y": 288,
"z": 0
},
"size": {
"width": 128.0,
"height": 86,
"depth": 0
},
"content_type": [
"plate",
"tip_rack",
"plates",
"tip_racks",
"tube_rack",
"adaptor"
]
}
]
},
"data": {}
} }
], ],
"links": [] "links": []

View File

@@ -0,0 +1,29 @@
{
"nodes": [
{
"id": "Liquid_Sensor_1",
"name": "XKC Sensor",
"children": [],
"parent": null,
"type": "device",
"class": "sensor.xkc_rs485",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"port": "/dev/tty.usbserial-3110",
"baudrate": 9600,
"device_id": 1,
"threshold": 300,
"timeout": 3.0
},
"data": {
"level": false,
"rssi": 0
}
}
],
"links": []
}

View File

@@ -0,0 +1,28 @@
{
"nodes": [
{
"id": "ZDT_Motor",
"name": "ZDT Motor",
"children": [],
"parent": null,
"type": "device",
"class": "motor.zdt_x42",
"position": {
"x": 0,
"y": 0,
"z": 0
},
"config": {
"port": "/dev/tty.usbserial-3110",
"baudrate": 115200,
"device_id": 1,
"debug": true
},
"data": {
"position": 0,
"status": "idle"
}
}
],
"links": []
}

View File

@@ -6,20 +6,180 @@
import argparse import argparse
import importlib import importlib
import locale import locale
import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path
from typing import List, Optional
from unilabos.utils.banner_print import print_status from unilabos.utils.banner_print import print_status
# ---------------------------------------------------------------------------
# 底层安装工具
# ---------------------------------------------------------------------------
def _is_chinese_locale() -> bool:
try:
lang = locale.getdefaultlocale()[0]
return bool(lang and ("zh" in lang.lower() or "chinese" in lang.lower()))
except Exception:
return False
_USE_UV: Optional[bool] = None
def _has_uv() -> bool:
global _USE_UV
if _USE_UV is None:
_USE_UV = shutil.which("uv") is not None
return _USE_UV
def _install_packages(
packages: List[str],
upgrade: bool = False,
label: str = "",
) -> bool:
"""
安装/升级一组包。优先 uv pip install回退 sys pip。
逐个安装,任意一个失败不影响后续包。
Returns:
True if all succeeded, False otherwise.
"""
if not packages:
return True
is_chinese = _is_chinese_locale()
use_uv = _has_uv()
failed: List[str] = []
for pkg in packages:
action_word = "升级" if upgrade else "安装"
if label:
print_status(f"[{label}] 正在{action_word} {pkg}...", "info")
else:
print_status(f"正在{action_word} {pkg}...", "info")
if use_uv:
cmd = ["uv", "pip", "install"]
if upgrade:
cmd.append("--upgrade")
cmd.append(pkg)
if is_chinese:
cmd.extend(["--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
else:
cmd = [sys.executable, "-m", "pip", "install"]
if upgrade:
cmd.append("--upgrade")
cmd.append(pkg)
if is_chinese:
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0:
installer = "uv" if use_uv else "pip"
print_status(f"{pkg} {action_word}成功 (via {installer})", "success")
else:
stderr_short = result.stderr.strip().split("\n")[-1] if result.stderr else "unknown error"
print_status(f"× {pkg} {action_word}失败: {stderr_short}", "error")
failed.append(pkg)
except subprocess.TimeoutExpired:
print_status(f"× {pkg} {action_word}超时 (300s)", "error")
failed.append(pkg)
except Exception as e:
print_status(f"× {pkg} {action_word}异常: {e}", "error")
failed.append(pkg)
if failed:
print_status(f"{len(failed)} 个包操作失败: {', '.join(failed)}", "error")
return False
return True
# ---------------------------------------------------------------------------
# requirements.txt 安装(可多次调用)
# ---------------------------------------------------------------------------
def install_requirements_txt(req_path: str | Path, label: str = "") -> bool:
"""
读取一个 requirements.txt 文件,检查缺失的包并安装。
Args:
req_path: requirements.txt 文件路径
label: 日志前缀标签(如 "device_package_sim"
Returns:
True if all ok, False if any install failed.
"""
req_path = Path(req_path)
if not req_path.exists():
return True
tag = label or req_path.parent.name
print_status(f"[{tag}] 检查依赖: {req_path}", "info")
reqs: List[str] = []
with open(req_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and not line.startswith("-"):
reqs.append(line)
if not reqs:
return True
missing: List[str] = []
for req in reqs:
pkg_import = req.split(">=")[0].split("==")[0].split("<")[0].split("[")[0].split(">")[0].strip()
pkg_import = pkg_import.replace("-", "_")
try:
importlib.import_module(pkg_import)
except ImportError:
missing.append(req)
if not missing:
print_status(f"[{tag}] ✓ 依赖检查通过 ({len(reqs)} 个包)", "success")
return True
print_status(f"[{tag}] 缺失 {len(missing)} 个依赖: {', '.join(missing)}", "warning")
return _install_packages(missing, label=tag)
def check_device_package_requirements(devices_dirs: list[str]) -> bool:
"""
检查 --devices 指定的所有外部设备包目录中的 requirements.txt。
对每个目录查找 requirements.txt先在目录内找再在父目录找
"""
if not devices_dirs:
return True
all_ok = True
for d in devices_dirs:
d_path = Path(d).resolve()
req_file = d_path / "requirements.txt"
if not req_file.exists():
req_file = d_path.parent / "requirements.txt"
if not req_file.exists():
continue
if not install_requirements_txt(req_file, label=d_path.name):
all_ok = False
return all_ok
# ---------------------------------------------------------------------------
# UniLabOS 核心环境检查
# ---------------------------------------------------------------------------
class EnvironmentChecker: class EnvironmentChecker:
"""环境检查器""" """环境检查器"""
def __init__(self): def __init__(self):
# 定义必需的包及其安装名称的映射
self.required_packages = { self.required_packages = {
# 包导入名 : pip安装名
# "pymodbus.framer.FramerType": "pymodbus==3.9.2",
"websockets": "websockets", "websockets": "websockets",
"msgcenterpy": "msgcenterpy", "msgcenterpy": "msgcenterpy",
"orjson": "orjson", "orjson": "orjson",
@@ -28,33 +188,23 @@ class EnvironmentChecker:
"crcmod": "crcmod-plus", "crcmod": "crcmod-plus",
} }
# 特殊安装包(需要特殊处理的包) # 中文 locale 下走 Gitee 镜像,规避 GitHub 拉取失败
self.special_packages = {"pylabrobot": "git+https://github.com/Xuwznln/pylabrobot.git"} pylabrobot_url = (
"git+https://gitee.com/xuwznln/pylabrobot.git"
if _is_chinese_locale()
else "git+https://github.com/Xuwznln/pylabrobot.git"
)
self.special_packages = {"pylabrobot": pylabrobot_url}
# 包版本要求(包名: 最低版本)
self.version_requirements = { self.version_requirements = {
"msgcenterpy": "0.1.8", # msgcenterpy 最低版本要求 "msgcenterpy": "0.1.8",
} }
self.missing_packages = [] self.missing_packages: List[tuple] = []
self.failed_installs = [] self.failed_installs: List[tuple] = []
self.packages_need_upgrade = [] self.packages_need_upgrade: List[tuple] = []
# 检测系统语言
self.is_chinese = self._is_chinese_locale()
def _is_chinese_locale(self) -> bool:
"""检测系统是否为中文环境"""
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 check_package_installed(self, package_name: str) -> bool: def check_package_installed(self, package_name: str) -> bool:
"""检查包是否已安装"""
try: try:
importlib.import_module(package_name) importlib.import_module(package_name)
return True return True
@@ -62,7 +212,6 @@ class EnvironmentChecker:
return False return False
def get_package_version(self, package_name: str) -> str | None: def get_package_version(self, package_name: str) -> str | None:
"""获取已安装包的版本"""
try: try:
module = importlib.import_module(package_name) module = importlib.import_module(package_name)
return getattr(module, "__version__", None) return getattr(module, "__version__", None)
@@ -70,88 +219,32 @@ class EnvironmentChecker:
return None return None
def compare_version(self, current: str, required: str) -> bool: def compare_version(self, current: str, required: str) -> bool:
"""
比较版本号
Returns:
True: current >= required
False: current < required
"""
try: try:
current_parts = [int(x) for x in current.split(".")] current_parts = [int(x) for x in current.split(".")]
required_parts = [int(x) for x in required.split(".")] required_parts = [int(x) for x in required.split(".")]
# 补齐长度
max_len = max(len(current_parts), len(required_parts)) max_len = max(len(current_parts), len(required_parts))
current_parts.extend([0] * (max_len - len(current_parts))) current_parts.extend([0] * (max_len - len(current_parts)))
required_parts.extend([0] * (max_len - len(required_parts))) required_parts.extend([0] * (max_len - len(required_parts)))
return current_parts >= required_parts return current_parts >= required_parts
except Exception: except Exception:
return True # 如果无法比较,假设版本满足要求 return True
def install_package(self, package_name: str, pip_name: str, upgrade: bool = False) -> bool:
"""安装包"""
try:
action = "升级" if upgrade else "安装"
print_status(f"正在{action} {package_name} ({pip_name})...", "info")
# 构建安装命令
cmd = [sys.executable, "-m", "pip", "install"]
# 如果是升级操作,添加 --upgrade 参数
if upgrade:
cmd.append("--upgrade")
cmd.append(pip_name)
# 如果是中文环境,使用清华镜像源
if self.is_chinese:
cmd.extend(["-i", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple"])
# 执行安装
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) # 5分钟超时
if result.returncode == 0:
print_status(f"{package_name} {action}成功", "success")
return True
else:
print_status(f"× {package_name} {action}失败: {result.stderr}", "error")
return False
except subprocess.TimeoutExpired:
print_status(f"× {package_name} {action}超时", "error")
return False
except Exception as e:
print_status(f"× {package_name} {action}异常: {str(e)}", "error")
return False
def upgrade_package(self, package_name: str, pip_name: str) -> bool:
"""升级包"""
return self.install_package(package_name, pip_name, upgrade=True)
def check_all_packages(self) -> bool: def check_all_packages(self) -> bool:
"""检查所有必需的包"""
print_status("开始检查环境依赖...", "info") print_status("开始检查环境依赖...", "info")
# 检查常规包
for import_name, pip_name in self.required_packages.items(): for import_name, pip_name in self.required_packages.items():
if not self.check_package_installed(import_name): if not self.check_package_installed(import_name):
self.missing_packages.append((import_name, pip_name)) self.missing_packages.append((import_name, pip_name))
else: elif import_name in self.version_requirements:
# 检查版本要求 current_version = self.get_package_version(import_name)
if import_name in self.version_requirements: required_version = self.version_requirements[import_name]
current_version = self.get_package_version(import_name) if current_version and not self.compare_version(current_version, required_version):
required_version = self.version_requirements[import_name] print_status(
f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})",
"warning",
)
self.packages_need_upgrade.append((import_name, pip_name))
if current_version:
if not self.compare_version(current_version, required_version):
print_status(
f"{import_name} 版本过低 (当前: {current_version}, 需要: >={required_version})",
"warning",
)
self.packages_need_upgrade.append((import_name, pip_name))
# 检查特殊包
for package_name, install_url in self.special_packages.items(): for package_name, install_url in self.special_packages.items():
if not self.check_package_installed(package_name): if not self.check_package_installed(package_name):
self.missing_packages.append((package_name, install_url)) self.missing_packages.append((package_name, install_url))
@@ -170,7 +263,6 @@ class EnvironmentChecker:
return False return False
def install_missing_packages(self, auto_install: bool = True) -> bool: def install_missing_packages(self, auto_install: bool = True) -> bool:
"""安装缺失的包"""
if not self.missing_packages and not self.packages_need_upgrade: if not self.missing_packages and not self.packages_need_upgrade:
return True return True
@@ -178,62 +270,36 @@ class EnvironmentChecker:
if self.missing_packages: if self.missing_packages:
print_status("缺失以下包:", "warning") print_status("缺失以下包:", "warning")
for import_name, pip_name in self.missing_packages: for import_name, pip_name in self.missing_packages:
print_status(f" - {import_name} (pip install {pip_name})", "warning") print_status(f" - {import_name} ({pip_name})", "warning")
if self.packages_need_upgrade: if self.packages_need_upgrade:
print_status("需要升级以下包:", "warning") print_status("需要升级以下包:", "warning")
for import_name, pip_name in self.packages_need_upgrade: for import_name, pip_name in self.packages_need_upgrade:
print_status(f" - {import_name} (pip install --upgrade {pip_name})", "warning") print_status(f" - {import_name} ({pip_name})", "warning")
return False return False
# 安装缺失的包
if self.missing_packages: if self.missing_packages:
print_status(f"开始自动安装 {len(self.missing_packages)} 个缺失的包...", "info") pkgs = [pip_name for _, pip_name in self.missing_packages]
if not _install_packages(pkgs, label="unilabos"):
self.failed_installs.extend(self.missing_packages)
success_count = 0
for import_name, pip_name in self.missing_packages:
if self.install_package(import_name, pip_name):
success_count += 1
else:
self.failed_installs.append((import_name, pip_name))
print_status(f"✓ 成功安装 {success_count}/{len(self.missing_packages)} 个包", "success")
# 升级需要更新的包
if self.packages_need_upgrade: if self.packages_need_upgrade:
print_status(f"开始自动升级 {len(self.packages_need_upgrade)} 个包...", "info") pkgs = [pip_name for _, pip_name in self.packages_need_upgrade]
if not _install_packages(pkgs, upgrade=True, label="unilabos"):
self.failed_installs.extend(self.packages_need_upgrade)
upgrade_success_count = 0 return not self.failed_installs
for import_name, pip_name in self.packages_need_upgrade:
if self.upgrade_package(import_name, pip_name):
upgrade_success_count += 1
else:
self.failed_installs.append((import_name, pip_name))
print_status(f"✓ 成功升级 {upgrade_success_count}/{len(self.packages_need_upgrade)} 个包", "success")
if self.failed_installs:
print_status(f"{len(self.failed_installs)} 个包操作失败:", "error")
for import_name, pip_name in self.failed_installs:
print_status(f" - {import_name} ({pip_name})", "error")
return False
return True
def verify_installation(self) -> bool: def verify_installation(self) -> bool:
"""验证安装结果"""
if not self.missing_packages and not self.packages_need_upgrade: if not self.missing_packages and not self.packages_need_upgrade:
return True return True
print_status("验证安装结果...", "info") print_status("验证安装结果...", "info")
failed_verification = [] failed_verification = []
# 验证新安装的包
for import_name, pip_name in self.missing_packages: for import_name, pip_name in self.missing_packages:
if not self.check_package_installed(import_name): if not self.check_package_installed(import_name):
failed_verification.append((import_name, pip_name)) failed_verification.append((import_name, pip_name))
# 验证升级的包
for import_name, pip_name in self.packages_need_upgrade: for import_name, pip_name in self.packages_need_upgrade:
if not self.check_package_installed(import_name): if not self.check_package_installed(import_name):
failed_verification.append((import_name, pip_name)) failed_verification.append((import_name, pip_name))
@@ -270,17 +336,14 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b
""" """
checker = EnvironmentChecker() checker = EnvironmentChecker()
# 检查包
if checker.check_all_packages(): if checker.check_all_packages():
return True return True
# 安装缺失的包
if not checker.install_missing_packages(auto_install): if not checker.install_missing_packages(auto_install):
if show_details: if show_details:
print_status("请手动安装缺失的包后重新启动程序", "error") print_status("请手动安装缺失的包后重新启动程序", "error")
return False return False
# 验证安装
if not checker.verify_installation(): if not checker.verify_installation():
if show_details: if show_details:
print_status("安装验证失败,请检查网络连接或手动安装", "error") print_status("安装验证失败,请检查网络连接或手动安装", "error")
@@ -290,14 +353,12 @@ def check_environment(auto_install: bool = True, show_details: bool = True) -> b
if __name__ == "__main__": if __name__ == "__main__":
# 命令行参数解析
parser = argparse.ArgumentParser(description="UniLabOS 环境依赖检查工具") parser = argparse.ArgumentParser(description="UniLabOS 环境依赖检查工具")
parser.add_argument("--no-auto-install", action="store_true", help="仅检查环境,不自动安装缺失的包") parser.add_argument("--no-auto-install", action="store_true", help="仅检查环境,不自动安装缺失的包")
parser.add_argument("--silent", action="store_true", help="静默模式,不显示详细信息") parser.add_argument("--silent", action="store_true", help="静默模式,不显示详细信息")
args = parser.parse_args() args = parser.parse_args()
# 执行环境检查
auto_install = not args.no_auto_install auto_install = not args.no_auto_install
show_details = not args.silent show_details = not args.silent

Some files were not shown because too many files have changed in this diff Show More