mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 20:59:20 +00:00
Compare commits
10 Commits
feat/lab_r
...
e4e4bfbe20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e4bfbe20 | ||
|
|
64c748d921 | ||
|
|
15ff0e9d30 | ||
|
|
f8a52860ad | ||
|
|
e30c01d54e | ||
|
|
37ec49f318 | ||
|
|
6bf57f18c1 | ||
|
|
c4a3be1498 | ||
|
|
e11070315d | ||
|
|
50ebcad9d7 |
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos
|
||||
version: 0.10.18
|
||||
version: 0.10.17
|
||||
|
||||
source:
|
||||
path: ../../unilabos
|
||||
@@ -46,15 +46,13 @@ requirements:
|
||||
- jinja2
|
||||
- requests
|
||||
- uvicorn
|
||||
- if: not osx
|
||||
then:
|
||||
- opcua
|
||||
- opcua # [not osx]
|
||||
- pyserial
|
||||
- pandas
|
||||
- pymodbus
|
||||
- matplotlib
|
||||
- pylibftdi
|
||||
- uni-lab::unilabos-env ==0.10.18
|
||||
- uni-lab::unilabos-env ==0.10.17
|
||||
|
||||
about:
|
||||
repository: https://github.com/deepmodeling/Uni-Lab-OS
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-env
|
||||
version: 0.10.18
|
||||
version: 0.10.17
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
package:
|
||||
name: unilabos-full
|
||||
version: 0.10.18
|
||||
version: 0.10.17
|
||||
|
||||
build:
|
||||
noarch: generic
|
||||
@@ -11,7 +11,7 @@ build:
|
||||
requirements:
|
||||
run:
|
||||
# Base unilabos package (includes unilabos-env)
|
||||
- uni-lab::unilabos ==0.10.18
|
||||
- uni-lab::unilabos ==0.10.17
|
||||
# Documentation tools
|
||||
- sphinx
|
||||
- sphinx_rtd_theme
|
||||
|
||||
328
.cursor/rules/device-drivers.mdc
Normal file
328
.cursor/rules/device-drivers.mdc
Normal 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
|
||||
240
.cursor/rules/protocol-development.mdc
Normal file
240
.cursor/rules/protocol-development.mdc
Normal 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` - 蒸发操作
|
||||
319
.cursor/rules/registry-config.mdc
Normal file
319
.cursor/rules/registry-config.mdc
Normal 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
|
||||
```
|
||||
233
.cursor/rules/ros-integration.mdc
Normal file
233
.cursor/rules/ros-integration.mdc
Normal 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 方法中正确清理资源
|
||||
357
.cursor/rules/testing-patterns.mdc
Normal file
357
.cursor/rules/testing-patterns.mdc
Normal 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. **断言清晰**: 每个断言只验证一件事
|
||||
353
.cursor/rules/unilabos-project.mdc
Normal file
353
.cursor/rules/unilabos-project.mdc
Normal 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类
|
||||
- 支持定时搅拌和持续搅拌模式
|
||||
- 添加速度验证逻辑
|
||||
```
|
||||
188
.cursorignore
Normal file
188
.cursorignore
Normal 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-*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -452,9 +452,8 @@ unilab --ak your_ak --sk your_sk -g test/experiments/mock_devices/mock_all.json
|
||||
**操作步骤:**
|
||||
|
||||
1. 将两个 `container` 拖拽到 `workstation` 中
|
||||
2. 将 `virtual_multiway_valve` 拖拽到 `workstation` 中
|
||||
3. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
4. 在画布上连接它们(建立父子关系)
|
||||
2. 将 `virtual_transfer_pump` 拖拽到 `workstation` 中
|
||||
3. 在画布上连接它们(建立父子关系)
|
||||
|
||||

|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 275 KiB |
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: ros-humble-unilabos-msgs
|
||||
version: 0.10.18
|
||||
version: 0.10.17
|
||||
source:
|
||||
path: ../../unilabos_msgs
|
||||
target_directory: src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package:
|
||||
name: unilabos
|
||||
version: "0.10.18"
|
||||
version: "0.10.17"
|
||||
|
||||
source:
|
||||
path: ../..
|
||||
|
||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ package_name = 'unilabos'
|
||||
|
||||
setup(
|
||||
name=package_name,
|
||||
version='0.10.18',
|
||||
version='0.10.17',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=['setuptools'],
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.10.18"
|
||||
__version__ = "0.10.17"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
@@ -172,12 +171,6 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Disable sending update feedback to server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Test mode: all actions simulate execution and return mock results without running real hardware",
|
||||
)
|
||||
# workflow upload subcommand
|
||||
workflow_parser = subparsers.add_parser(
|
||||
"workflow_upload",
|
||||
@@ -211,12 +204,6 @@ def parse_args():
|
||||
default=False,
|
||||
help="Whether to publish the workflow (default: False)",
|
||||
)
|
||||
workflow_parser.add_argument(
|
||||
"--description",
|
||||
type=str,
|
||||
default="",
|
||||
help="Workflow description, used when publishing the workflow",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
@@ -244,60 +231,52 @@ def main():
|
||||
# 加载配置文件,优先加载config,然后从env读取
|
||||
config_path = args_dict.get("config")
|
||||
|
||||
# === 解析 working_dir ===
|
||||
# 规则1: working_dir 传入 → 检测 unilabos_data 子目录,已是则不修改
|
||||
# 规则2: 仅 config_path 传入 → 用其父目录作为 working_dir
|
||||
# 规则4: 两者都传入 → 各用各的,但 working_dir 仍做 unilabos_data 子目录检测
|
||||
raw_working_dir = args_dict.get("working_dir")
|
||||
if raw_working_dir:
|
||||
working_dir = os.path.abspath(raw_working_dir)
|
||||
elif config_path and os.path.exists(config_path):
|
||||
working_dir = os.path.dirname(os.path.abspath(config_path))
|
||||
else:
|
||||
if check_mode:
|
||||
args_dict["working_dir"] = os.path.abspath(os.getcwd())
|
||||
# 当 skip_env_check 时,默认使用当前目录作为 working_dir
|
||||
if skip_env_check and not args_dict.get("working_dir") and not config_path:
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
|
||||
# unilabos_data 子目录自动检测
|
||||
if os.path.basename(working_dir) != "unilabos_data":
|
||||
unilabos_data_sub = os.path.join(working_dir, "unilabos_data")
|
||||
if os.path.isdir(unilabos_data_sub):
|
||||
working_dir = unilabos_data_sub
|
||||
elif not raw_working_dir and not (config_path and os.path.exists(config_path)):
|
||||
# 未显式指定路径,默认使用 cwd/unilabos_data
|
||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||
|
||||
# === 解析 config_path ===
|
||||
if config_path and not os.path.exists(config_path):
|
||||
# config_path 传入但不存在,尝试在 working_dir 中查找
|
||||
candidate = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(candidate):
|
||||
config_path = candidate
|
||||
print_status(f"在工作目录中发现配置文件: {config_path}", "info")
|
||||
else:
|
||||
print_status(
|
||||
f"配置文件 {config_path} 不存在,工作目录 {working_dir} 中也未找到 local_config.py,"
|
||||
f"请通过 --config 传入 local_config.py 文件路径",
|
||||
"error",
|
||||
)
|
||||
os._exit(1)
|
||||
elif not config_path:
|
||||
# 规则3: 未传入 config_path,尝试 working_dir/local_config.py
|
||||
candidate = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(candidate):
|
||||
config_path = candidate
|
||||
print_status(f"跳过环境检查模式:使用当前目录作为工作目录 {working_dir}", "info")
|
||||
# 检查当前目录是否有 local_config.py
|
||||
local_config_in_cwd = os.path.join(working_dir, "local_config.py")
|
||||
if os.path.exists(local_config_in_cwd):
|
||||
config_path = local_config_in_cwd
|
||||
print_status(f"发现本地配置文件: {config_path}", "info")
|
||||
else:
|
||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||
if check_mode or input() != "n":
|
||||
os.makedirs(working_dir, exist_ok=True)
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
shutil.copy(
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"),
|
||||
config_path,
|
||||
elif os.getcwd().endswith("unilabos_data"):
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
else:
|
||||
working_dir = os.path.abspath(os.path.join(os.getcwd(), "unilabos_data"))
|
||||
|
||||
if args_dict.get("working_dir"):
|
||||
working_dir = args_dict.get("working_dir", "")
|
||||
if config_path and not os.path.exists(config_path):
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
if not os.path.exists(config_path):
|
||||
print_status(
|
||||
f"当前工作目录 {working_dir} 未找到local_config.py,请通过 --config 传入 local_config.py 文件路径",
|
||||
"error",
|
||||
)
|
||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||
else:
|
||||
os._exit(1)
|
||||
elif config_path and os.path.exists(config_path):
|
||||
working_dir = os.path.dirname(config_path)
|
||||
elif os.path.exists(working_dir) and os.path.exists(os.path.join(working_dir, "local_config.py")):
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
elif not skip_env_check and not config_path and (
|
||||
not os.path.exists(working_dir) or not os.path.exists(os.path.join(working_dir, "local_config.py"))
|
||||
):
|
||||
print_status(f"未指定config路径,可通过 --config 传入 local_config.py 文件路径", "info")
|
||||
print_status(f"您是否为第一次使用?并将当前路径 {working_dir} 作为工作目录? (Y/n)", "info")
|
||||
if input() != "n":
|
||||
os.makedirs(working_dir, exist_ok=True)
|
||||
config_path = os.path.join(working_dir, "local_config.py")
|
||||
shutil.copy(
|
||||
os.path.join(os.path.dirname(os.path.dirname(__file__)), "config", "example_config.py"), config_path
|
||||
)
|
||||
print_status(f"已创建 local_config.py 路径: {config_path}", "info")
|
||||
else:
|
||||
os._exit(1)
|
||||
|
||||
# 加载配置文件 (check_mode 跳过)
|
||||
print_status(f"当前工作目录为 {working_dir}", "info")
|
||||
@@ -309,9 +288,7 @@ def main():
|
||||
|
||||
if hasattr(BasicConfig, "log_level"):
|
||||
logger.info(f"Log level set to '{BasicConfig.log_level}' from config file.")
|
||||
file_path = configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
if file_path is not None:
|
||||
logger.info(f"[LOG_FILE] {file_path}")
|
||||
configure_logger(loglevel=BasicConfig.log_level, working_dir=working_dir)
|
||||
|
||||
if args.addr != parser.get_default("addr"):
|
||||
if args.addr == "test":
|
||||
@@ -355,11 +332,8 @@ def main():
|
||||
BasicConfig.slave_no_host = args_dict.get("slave_no_host", False)
|
||||
BasicConfig.upload_registry = args_dict.get("upload_registry", False)
|
||||
BasicConfig.no_update_feedback = args_dict.get("no_update_feedback", False)
|
||||
BasicConfig.test_mode = args_dict.get("test_mode", False)
|
||||
if BasicConfig.test_mode:
|
||||
print_status("启用测试模式:所有动作将模拟执行,不调用真实硬件", "warning")
|
||||
BasicConfig.communication_protocol = "websocket"
|
||||
machine_name = platform.node()
|
||||
machine_name = os.popen("hostname").read().strip()
|
||||
machine_name = "".join([c if c.isalnum() or c == "_" else "_" for c in machine_name])
|
||||
BasicConfig.machine_name = machine_name
|
||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||
|
||||
@@ -54,7 +54,6 @@ class JobAddReq(BaseModel):
|
||||
action_type: str = Field(
|
||||
examples=["unilabos_msgs.action._str_single_input.StrSingleInput"], description="action type", default=""
|
||||
)
|
||||
sample_material: dict = Field(examples=[{"string": "string"}], description="sample uuid to material uuid")
|
||||
action_args: dict = Field(examples=[{"string": "string"}], description="action arguments", default_factory=dict)
|
||||
task_id: str = Field(examples=["task_id"], description="task uuid (auto-generated if empty)", default="")
|
||||
job_id: str = Field(examples=["job_id"], description="goal uuid (auto-generated if empty)", default="")
|
||||
|
||||
@@ -38,9 +38,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
||||
response = http_client.resource_registry({"resources": list(devices_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}s")
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(devices_to_register)} 个设备 {cost_time}ms")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||
logger.error(f"[UniLab Register] 设备注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 设备注册异常: {e}")
|
||||
|
||||
@@ -51,9 +51,9 @@ def register_devices_and_resources(lab_registry, gather_only=False) -> Optional[
|
||||
response = http_client.resource_registry({"resources": list(resources_to_register.values())})
|
||||
cost_time = time.time() - start_time
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}s")
|
||||
logger.info(f"[UniLab Register] 成功注册 {len(resources_to_register)} 个资源 {cost_time}ms")
|
||||
else:
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}s")
|
||||
logger.error(f"[UniLab Register] 资源注册失败: {response.status_code}, {response.text} {cost_time}ms")
|
||||
except Exception as e:
|
||||
logger.error(f"[UniLab Register] 资源注册异常: {e}")
|
||||
|
||||
|
||||
@@ -343,10 +343,9 @@ class HTTPClient:
|
||||
edges: List[Dict[str, Any]],
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
description: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
导入工作流到服务器,如果 published 为 True,则额外发起发布请求
|
||||
导入工作流到服务器
|
||||
|
||||
Args:
|
||||
name: 工作流名称(顶层)
|
||||
@@ -356,7 +355,6 @@ class HTTPClient:
|
||||
edges: 工作流边列表
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
description: 工作流描述,发布时使用
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据,包含 code 和 data (uuid, name)
|
||||
@@ -369,6 +367,7 @@ class HTTPClient:
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"tags": tags if tags is not None else [],
|
||||
"published": published,
|
||||
},
|
||||
}
|
||||
# 保存请求到文件
|
||||
@@ -389,51 +388,11 @@ class HTTPClient:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"导入工作流失败: {response.text}")
|
||||
return res
|
||||
# 导入成功后,如果需要发布则额外发起发布请求
|
||||
if published:
|
||||
imported_uuid = res.get("data", {}).get("uuid", workflow_uuid)
|
||||
publish_res = self.workflow_publish(imported_uuid, description)
|
||||
res["publish_result"] = publish_res
|
||||
return res
|
||||
else:
|
||||
logger.error(f"导入工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
def workflow_publish(self, workflow_uuid: str, description: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
发布工作流
|
||||
|
||||
Args:
|
||||
workflow_uuid: 工作流UUID
|
||||
description: 工作流描述
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据
|
||||
"""
|
||||
payload = {
|
||||
"uuid": workflow_uuid,
|
||||
"description": description,
|
||||
"published": True,
|
||||
}
|
||||
logger.info(f"正在发布工作流: {workflow_uuid}")
|
||||
response = requests.patch(
|
||||
f"{self.remote_addr}/lab/workflow/owner",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Lab {self.auth}"},
|
||||
timeout=60,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
res = response.json()
|
||||
if "code" in res and res["code"] != 0:
|
||||
logger.error(f"发布工作流失败: {response.text}")
|
||||
else:
|
||||
logger.info(f"工作流发布成功: {workflow_uuid}")
|
||||
return res
|
||||
else:
|
||||
logger.error(f"发布工作流失败: {response.status_code}, {response.text}")
|
||||
return {"code": response.status_code, "message": response.text}
|
||||
|
||||
|
||||
# 创建默认客户端实例
|
||||
http_client = HTTPClient()
|
||||
|
||||
@@ -327,7 +327,6 @@ def job_add(req: JobAddReq) -> JobData:
|
||||
queue_item,
|
||||
action_type=action_type,
|
||||
action_kwargs=action_args,
|
||||
sample_material=req.sample_material,
|
||||
server_info=server_info,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,7 +76,6 @@ class JobInfo:
|
||||
start_time: float
|
||||
last_update_time: float = field(default_factory=time.time)
|
||||
ready_timeout: Optional[float] = None # READY状态的超时时间
|
||||
always_free: bool = False # 是否为永久闲置动作(不受排队限制)
|
||||
|
||||
def update_timestamp(self):
|
||||
"""更新最后更新时间"""
|
||||
@@ -128,15 +127,6 @@ class DeviceActionManager:
|
||||
# 总是将job添加到all_jobs中
|
||||
self.all_jobs[job_info.job_id] = job_info
|
||||
|
||||
# always_free的动作不受排队限制,直接设为READY
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.READY
|
||||
job_info.update_timestamp()
|
||||
job_info.set_ready_timeout(10)
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.trace(f"[DeviceActionManager] Job {job_log} always_free, start immediately")
|
||||
return True
|
||||
|
||||
# 检查是否有正在执行或准备执行的任务
|
||||
if device_key in self.active_jobs:
|
||||
# 有正在执行或准备执行的任务,加入队列
|
||||
@@ -186,15 +176,11 @@ class DeviceActionManager:
|
||||
logger.error(f"[DeviceActionManager] Job {job_log} is not in READY status, current: {job_info.status}")
|
||||
return False
|
||||
|
||||
# always_free的job不需要检查active_jobs
|
||||
if not job_info.always_free:
|
||||
# 检查设备上是否是这个job
|
||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||
job_log = format_job_log(
|
||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||
)
|
||||
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||
return False
|
||||
# 检查设备上是否是这个job
|
||||
if device_key not in self.active_jobs or self.active_jobs[device_key].job_id != job_id:
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.error(f"[DeviceActionManager] Job {job_log} is not the active job for {device_key}")
|
||||
return False
|
||||
|
||||
# 开始执行任务,将状态从READY转换为STARTED
|
||||
job_info.status = JobStatus.STARTED
|
||||
@@ -217,13 +203,6 @@ class DeviceActionManager:
|
||||
job_info = self.all_jobs[job_id]
|
||||
device_key = job_info.device_action_key
|
||||
|
||||
# always_free的job直接清理,不影响队列
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.ENDED
|
||||
job_info.update_timestamp()
|
||||
del self.all_jobs[job_id]
|
||||
return None
|
||||
|
||||
# 移除活跃任务
|
||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||
del self.active_jobs[device_key]
|
||||
@@ -255,14 +234,9 @@ class DeviceActionManager:
|
||||
return None
|
||||
|
||||
def get_active_jobs(self) -> List[JobInfo]:
|
||||
"""获取所有正在执行的任务(含active_jobs和always_free的STARTED job)"""
|
||||
"""获取所有正在执行的任务"""
|
||||
with self.lock:
|
||||
jobs = list(self.active_jobs.values())
|
||||
# 补充 always_free 的 STARTED job(它们不在 active_jobs 中)
|
||||
for job in self.all_jobs.values():
|
||||
if job.always_free and job.status == JobStatus.STARTED and job not in jobs:
|
||||
jobs.append(job)
|
||||
return jobs
|
||||
return list(self.active_jobs.values())
|
||||
|
||||
def get_queued_jobs(self) -> List[JobInfo]:
|
||||
"""获取所有排队中的任务"""
|
||||
@@ -287,14 +261,6 @@ class DeviceActionManager:
|
||||
job_info = self.all_jobs[job_id]
|
||||
device_key = job_info.device_action_key
|
||||
|
||||
# always_free的job直接清理
|
||||
if job_info.always_free:
|
||||
job_info.status = JobStatus.ENDED
|
||||
del self.all_jobs[job_id]
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.trace(f"[DeviceActionManager] Always-free job {job_log} cancelled")
|
||||
return True
|
||||
|
||||
# 如果是正在执行的任务
|
||||
if device_key in self.active_jobs and self.active_jobs[device_key].job_id == job_id:
|
||||
# 清理active job状态
|
||||
@@ -368,18 +334,13 @@ class DeviceActionManager:
|
||||
timeout_jobs = []
|
||||
|
||||
with self.lock:
|
||||
# 收集所有需要检查的 READY 任务(active_jobs + always_free READY jobs)
|
||||
ready_candidates = list(self.active_jobs.values())
|
||||
for job in self.all_jobs.values():
|
||||
if job.always_free and job.status == JobStatus.READY and job not in ready_candidates:
|
||||
ready_candidates.append(job)
|
||||
|
||||
ready_jobs_count = sum(1 for job in ready_candidates if job.status == JobStatus.READY)
|
||||
# 统计READY状态的任务数量
|
||||
ready_jobs_count = sum(1 for job in self.active_jobs.values() if job.status == JobStatus.READY)
|
||||
if ready_jobs_count > 0:
|
||||
logger.trace(f"[DeviceActionManager] Checking {ready_jobs_count} READY jobs for timeout") # type: ignore # noqa: E501
|
||||
|
||||
# 找到所有超时的READY任务(只检测,不处理)
|
||||
for job_info in ready_candidates:
|
||||
for job_info in self.active_jobs.values():
|
||||
if job_info.is_ready_timeout():
|
||||
timeout_jobs.append(job_info)
|
||||
job_log = format_job_log(
|
||||
@@ -584,7 +545,7 @@ class MessageProcessor:
|
||||
try:
|
||||
message_str = json.dumps(msg, ensure_ascii=False)
|
||||
await self.websocket.send(message_str)
|
||||
# logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||
logger.trace(f"[MessageProcessor] Message sent: {msg.get('action', 'unknown')}") # type: ignore # noqa: E501
|
||||
except Exception as e:
|
||||
logger.error(f"[MessageProcessor] Failed to send message: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -647,24 +608,6 @@ class MessageProcessor:
|
||||
if host_node:
|
||||
host_node.handle_pong_response(pong_data)
|
||||
|
||||
def _check_action_always_free(self, device_id: str, action_name: str) -> bool:
|
||||
"""检查该action是否标记为always_free,通过HostNode统一的_action_value_mappings查找"""
|
||||
try:
|
||||
host_node = HostNode.get_instance(0)
|
||||
if not host_node:
|
||||
return False
|
||||
# noinspection PyProtectedMember
|
||||
action_mappings = host_node._action_value_mappings.get(device_id)
|
||||
if not action_mappings:
|
||||
return False
|
||||
# 尝试直接匹配或 auto- 前缀匹配
|
||||
for key in [action_name, f"auto-{action_name}"]:
|
||||
if key in action_mappings:
|
||||
return action_mappings[key].get("always_free", False)
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _handle_query_action_state(self, data: Dict[str, Any]):
|
||||
"""处理query_action_state消息"""
|
||||
device_id = data.get("device_id", "")
|
||||
@@ -679,9 +622,6 @@ class MessageProcessor:
|
||||
|
||||
device_action_key = f"/devices/{device_id}/{action_name}"
|
||||
|
||||
# 检查action是否为always_free
|
||||
action_always_free = self._check_action_always_free(device_id, action_name)
|
||||
|
||||
# 创建任务信息
|
||||
job_info = JobInfo(
|
||||
job_id=job_id,
|
||||
@@ -691,7 +631,6 @@ class MessageProcessor:
|
||||
device_action_key=device_action_key,
|
||||
status=JobStatus.QUEUE,
|
||||
start_time=time.time(),
|
||||
always_free=action_always_free,
|
||||
)
|
||||
|
||||
# 添加到设备管理器
|
||||
@@ -718,8 +657,6 @@ class MessageProcessor:
|
||||
async def _handle_job_start(self, data: Dict[str, Any]):
|
||||
"""处理job_start消息"""
|
||||
try:
|
||||
if not data.get("sample_material"):
|
||||
data["sample_material"] = {}
|
||||
req = JobAddReq(**data)
|
||||
|
||||
job_log = format_job_log(req.job_id, req.task_id, req.device_id, req.action)
|
||||
@@ -751,7 +688,6 @@ class MessageProcessor:
|
||||
queue_item,
|
||||
action_type=req.action_type,
|
||||
action_kwargs=req.action_args,
|
||||
sample_material=req.sample_material,
|
||||
server_info=req.server_info,
|
||||
)
|
||||
|
||||
@@ -1184,11 +1120,6 @@ class QueueProcessor:
|
||||
logger.debug(f"[QueueProcessor] Sending busy status for {len(queued_jobs)} queued jobs")
|
||||
|
||||
for job_info in queued_jobs:
|
||||
# 快照可能已过期:在遍历过程中 end_job() 可能已将此 job 移至 READY,
|
||||
# 此时不应再发送 busy/need_more,否则会覆盖已发出的 free=True 通知
|
||||
if job_info.status != JobStatus.QUEUE:
|
||||
continue
|
||||
|
||||
message = {
|
||||
"action": "report_action_state",
|
||||
"data": {
|
||||
@@ -1370,7 +1301,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
# logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
logger.trace(f"[WebSocketClient] Device status published: {device_id}.{property_name}")
|
||||
|
||||
def publish_job_status(
|
||||
self, feedback_data: dict, item: QueueItem, status: str, return_info: Optional[dict] = None
|
||||
|
||||
@@ -95,29 +95,8 @@ def get_vessel_liquid_volume(G: nx.DiGraph, vessel: str) -> float:
|
||||
return total_volume
|
||||
|
||||
|
||||
def is_integrated_pump(node_class: str, node_name: str = "") -> bool:
|
||||
"""
|
||||
判断是否为泵阀一体设备
|
||||
"""
|
||||
class_lower = (node_class or "").lower()
|
||||
name_lower = (node_name or "").lower()
|
||||
|
||||
if "pump" not in class_lower and "pump" not in name_lower:
|
||||
return False
|
||||
|
||||
integrated_markers = [
|
||||
"valve",
|
||||
"pump_valve",
|
||||
"pumpvalve",
|
||||
"integrated",
|
||||
"transfer_pump",
|
||||
]
|
||||
|
||||
for marker in integrated_markers:
|
||||
if marker in class_lower or marker in name_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
def is_integrated_pump(node_name):
|
||||
return "pump" in node_name and "valve" in node_name
|
||||
|
||||
|
||||
def find_connected_pump(G, valve_node):
|
||||
@@ -207,9 +186,7 @@ def build_pump_valve_maps(G, pump_backbone):
|
||||
debug_print(f"🔧 过滤后的骨架: {filtered_backbone}")
|
||||
|
||||
for node in filtered_backbone:
|
||||
node_data = G.nodes.get(node, {})
|
||||
node_class = node_data.get("class", "") or ""
|
||||
if is_integrated_pump(node_class, node):
|
||||
if is_integrated_pump(G.nodes[node]["class"]):
|
||||
pumps_from_node[node] = node
|
||||
valve_from_node[node] = node
|
||||
debug_print(f" - 集成泵-阀: {node}")
|
||||
|
||||
@@ -23,7 +23,6 @@ class BasicConfig:
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||
test_mode = False # 测试模式,所有动作不实际执行,返回模拟结果
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
@@ -146,5 +145,5 @@ def load_config(config_path=None):
|
||||
traceback.print_exc()
|
||||
exit(1)
|
||||
else:
|
||||
config_path = os.path.join(os.path.dirname(__file__), "example_config.py")
|
||||
config_path = os.path.join(os.path.dirname(__file__), "local_config.py")
|
||||
load_config(config_path)
|
||||
|
||||
@@ -201,42 +201,17 @@ class ResourceVisualization:
|
||||
self.moveit_controllers_yaml['moveit_simple_controller_manager'][f"{name}_{controller_name}"] = moveit_dict['moveit_simple_controller_manager'][controller_name]
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _ensure_ros2_env() -> dict:
|
||||
"""确保 ROS2 环境变量正确设置,返回可用于子进程的 env dict"""
|
||||
import sys
|
||||
env = dict(os.environ)
|
||||
conda_prefix = os.path.dirname(os.path.dirname(sys.executable))
|
||||
|
||||
if "AMENT_PREFIX_PATH" not in env or not env["AMENT_PREFIX_PATH"].strip():
|
||||
candidate = os.pathsep.join([conda_prefix, os.path.join(conda_prefix, "Library")])
|
||||
env["AMENT_PREFIX_PATH"] = candidate
|
||||
os.environ["AMENT_PREFIX_PATH"] = candidate
|
||||
|
||||
extra_bin_dirs = [
|
||||
os.path.join(conda_prefix, "Library", "bin"),
|
||||
os.path.join(conda_prefix, "Library", "lib"),
|
||||
os.path.join(conda_prefix, "Scripts"),
|
||||
conda_prefix,
|
||||
]
|
||||
current_path = env.get("PATH", "")
|
||||
for d in extra_bin_dirs:
|
||||
if d not in current_path:
|
||||
current_path = d + os.pathsep + current_path
|
||||
env["PATH"] = current_path
|
||||
os.environ["PATH"] = current_path
|
||||
|
||||
return env
|
||||
|
||||
def create_launch_description(self) -> LaunchDescription:
|
||||
"""
|
||||
创建launch描述,包含robot_state_publisher和move_group节点
|
||||
|
||||
Args:
|
||||
urdf_str: URDF文本
|
||||
|
||||
Returns:
|
||||
LaunchDescription: launch描述对象
|
||||
"""
|
||||
launch_env = self._ensure_ros2_env()
|
||||
|
||||
# 检查ROS 2环境变量
|
||||
if "AMENT_PREFIX_PATH" not in os.environ:
|
||||
raise OSError(
|
||||
"ROS 2环境未正确设置。需要设置 AMENT_PREFIX_PATH 环境变量。\n"
|
||||
@@ -315,7 +290,7 @@ class ResourceVisualization:
|
||||
{"robot_description": robot_description},
|
||||
ros2_controllers,
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for controller in self.moveit_controllers_yaml['moveit_simple_controller_manager']['controller_names']:
|
||||
@@ -325,7 +300,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=[f"{controller}", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
controllers.append(
|
||||
@@ -334,7 +309,7 @@ class ResourceVisualization:
|
||||
executable="spawner",
|
||||
arguments=["joint_state_broadcaster", "--controller-manager", f"controller_manager"],
|
||||
output="screen",
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
)
|
||||
for i in controllers:
|
||||
@@ -342,6 +317,7 @@ class ResourceVisualization:
|
||||
else:
|
||||
ros2_controllers = None
|
||||
|
||||
# 创建robot_state_publisher节点
|
||||
robot_state_publisher = nd(
|
||||
package='robot_state_publisher',
|
||||
executable='robot_state_publisher',
|
||||
@@ -351,8 +327,9 @@ class ResourceVisualization:
|
||||
'robot_description': robot_description,
|
||||
'use_sim_time': False
|
||||
},
|
||||
# kinematics_dict
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -384,7 +361,7 @@ class ResourceVisualization:
|
||||
executable='move_group',
|
||||
output='screen',
|
||||
parameters=moveit_params,
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
|
||||
|
||||
@@ -402,11 +379,13 @@ class ResourceVisualization:
|
||||
arguments=['-d', f"{str(self.mesh_path)}/view_robot.rviz"],
|
||||
output='screen',
|
||||
parameters=[
|
||||
{'robot_description_kinematics': kinematics_dict},
|
||||
{'robot_description_kinematics': kinematics_dict,
|
||||
},
|
||||
robot_description_planning,
|
||||
planning_pipelines,
|
||||
|
||||
],
|
||||
env=launch_env,
|
||||
env=dict(os.environ)
|
||||
)
|
||||
self.launch_description.add_action(rviz_node)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,6 @@ from unilabos.devices.liquid_handling.liquid_handler_abstract import (
|
||||
TransferLiquidReturn,
|
||||
)
|
||||
from unilabos.registry.placeholder_type import ResourceSlot
|
||||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
|
||||
@@ -91,107 +90,20 @@ class PRCXI9300Deck(Deck):
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
"""
|
||||
|
||||
# T1-T16 默认位置 (4列×4行)
|
||||
_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
|
||||
(0, 192, 0), (138, 192, 0), (276, 192, 0), (414, 192, 0), # T9-T12
|
||||
(0, 288, 0), (138, 288, 0), (276, 288, 0), (414, 288, 0), # T13-T16
|
||||
]
|
||||
_DEFAULT_SITE_SIZE = {"width": 128.0, "height": 86, "depth": 0}
|
||||
_DEFAULT_CONTENT_TYPE = ["plate", "tip_rack", "plates", "tip_racks", "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=name)
|
||||
if sites is not None:
|
||||
self.sites: List[Dict[str, Any]] = [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),
|
||||
})
|
||||
# _ordering: label -> None, 用于外部通过 list(keys()).index(site) 将 Tn 转换为 spot index
|
||||
self._ordering = collections.OrderedDict(
|
||||
(site["label"], None) for site in self.sites
|
||||
)
|
||||
self.root = self.get_root()
|
||||
|
||||
def _get_site_location(self, idx: int) -> Coordinate:
|
||||
pos = self.sites[idx]["position"]
|
||||
return Coordinate(pos["x"], pos["y"], pos["z"])
|
||||
|
||||
def _get_site_resource(self, idx: int) -> Optional[Resource]:
|
||||
site_loc = self._get_site_location(idx)
|
||||
for child in self.children:
|
||||
if child.location == site_loc:
|
||||
return child
|
||||
return None
|
||||
|
||||
def assign_child_resource(
|
||||
self,
|
||||
resource: Resource,
|
||||
location: Optional[Coordinate] = None,
|
||||
reassign: bool = True,
|
||||
spot: Optional[int] = None,
|
||||
):
|
||||
idx = spot
|
||||
if spot is not None:
|
||||
idx = spot
|
||||
else:
|
||||
for i, site in enumerate(self.sites):
|
||||
site_loc = self._get_site_location(i)
|
||||
if site.get("label") == resource.name:
|
||||
idx = i
|
||||
break
|
||||
if location is not None and site_loc == location:
|
||||
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 on deck '{self.name}' for resource '{resource.name}'")
|
||||
|
||||
if not reassign and self._get_site_resource(idx) is not None:
|
||||
existing = self.root.get_resource(resource.name)
|
||||
if existing is not resource and existing.parent is not None:
|
||||
existing.parent.unassign_child_resource(existing)
|
||||
|
||||
|
||||
loc = self._get_site_location(idx)
|
||||
super().assign_child_resource(resource, location=loc, reassign=reassign)
|
||||
def __init__(self, name: str, size_x: float, size_y: float, size_z: float, **kwargs):
|
||||
super().__init__(name, size_x, size_y, size_z)
|
||||
self.slots = [None] * 16 # PRCXI 9300/9320 最大有 16 个槽位
|
||||
self.slot_locations = [Coordinate(0, 0, 0)] * 16
|
||||
|
||||
def assign_child_at_slot(self, resource: Resource, slot: int, reassign: bool = False) -> None:
|
||||
self.assign_child_resource(resource, spot=slot - 1, reassign=reassign)
|
||||
if self.slots[slot - 1] is not None and not reassign:
|
||||
raise ValueError(f"Spot {slot} is already occupied")
|
||||
|
||||
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 is not None else None,
|
||||
"position": site["position"],
|
||||
"size": site["size"],
|
||||
"content_type": site["content_type"],
|
||||
})
|
||||
data["sites"] = sites_out
|
||||
return data
|
||||
self.slots[slot - 1] = resource
|
||||
super().assign_child_resource(resource, location=self.slot_locations[slot - 1])
|
||||
|
||||
|
||||
class PRCXI9300Container(Container):
|
||||
class PRCXI9300Container(Plate):
|
||||
"""PRCXI 9300 的专用 Container 类,继承自 Plate,用于槽位定位和未知模块。
|
||||
|
||||
该类定义了 PRCXI 9300 的工作台布局和槽位信息。
|
||||
@@ -204,10 +116,11 @@ class PRCXI9300Container(Container):
|
||||
size_y: float,
|
||||
size_z: float,
|
||||
category: str,
|
||||
ordering: collections.OrderedDict,
|
||||
model: Optional[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, model=model)
|
||||
super().__init__(name, size_x, size_y, size_z, category=category, ordering=ordering, model=model)
|
||||
self._unilabos_state = {}
|
||||
|
||||
def load_state(self, state: Dict[str, Any]) -> None:
|
||||
@@ -335,15 +248,14 @@ class PRCXI9300TipRack(TipRack):
|
||||
if ordered_items is not None:
|
||||
items = ordered_items
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值类型来决定如何处理:
|
||||
# - 字符串值(从 JSON 反序列化): 只用键创建 ordering_param
|
||||
# - None 值(从第二次往返序列化): 同样只用键创建 ordering_param
|
||||
# - 对象值(已经是实际的 Resource 对象): 直接作为 ordered_items 使用
|
||||
first_val = next(iter(ordering.values()), None) if ordering else None
|
||||
if not ordering or first_val is None or isinstance(first_val, str):
|
||||
# ordering 的值是字符串或 None,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 TipRack 自己创建 Tip 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TipRack 自己创建 Tip 对象
|
||||
items = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
@@ -485,15 +397,14 @@ class PRCXI9300TubeRack(TubeRack):
|
||||
items_to_pass = ordered_items
|
||||
ordering_param = None
|
||||
elif ordering is not None:
|
||||
# 检查 ordering 中的值类型来决定如何处理:
|
||||
# - 字符串值(从 JSON 反序列化): 只用键创建 ordering_param
|
||||
# - None 值(从第二次往返序列化): 同样只用键创建 ordering_param
|
||||
# - 对象值(已经是实际的 Resource 对象): 直接作为 ordered_items 使用
|
||||
first_val = next(iter(ordering.values()), None) if ordering else None
|
||||
if not ordering or first_val is None or isinstance(first_val, str):
|
||||
# ordering 的值是字符串或 None,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 检查 ordering 中的值是否是字符串(从 JSON 反序列化时的情况)
|
||||
# 如果是字符串,说明这是位置名称,需要让 TubeRack 自己创建 Tube 对象
|
||||
# 我们只传递位置信息(键),不传递值,使用 ordering 参数
|
||||
if ordering and isinstance(next(iter(ordering.values()), None), str):
|
||||
# ordering 的值是字符串,只使用键(位置信息)创建新的 OrderedDict
|
||||
# 传递 ordering 参数而不是 ordered_items,让 TubeRack 自己创建 Tube 对象
|
||||
items_to_pass = None
|
||||
# 使用 ordering 参数,只包含位置信息(键)
|
||||
ordering_param = collections.OrderedDict((k, None) for k in ordering.keys())
|
||||
else:
|
||||
# ordering 的值已经是对象,可以直接使用
|
||||
@@ -654,14 +565,14 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
tablets_info = []
|
||||
count = 0
|
||||
for child in deck.children:
|
||||
# 如果放其他类型的物料,是不可以的
|
||||
if hasattr(child, "_unilabos_state") and "Material" in child._unilabos_state:
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child._unilabos_state["Material"]
|
||||
if child.children:
|
||||
if "Material" in child.children[0]._unilabos_state:
|
||||
number = int(child.name.replace("T", ""))
|
||||
tablets_info.append(
|
||||
WorkTablets(
|
||||
Number=number, Code=f"T{number}", Material=child.children[0]._unilabos_state["Material"]
|
||||
)
|
||||
)
|
||||
)
|
||||
if is_9320:
|
||||
print("当前设备是9320")
|
||||
# 始终初始化 step_mode 属性
|
||||
@@ -684,7 +595,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
return super().set_liquid(wells, liquid_names, volumes)
|
||||
|
||||
def set_liquid_from_plate(
|
||||
self, plate: ResourceSlot, well_names: list[str], liquid_names: list[str], volumes: list[float]
|
||||
self, plate: List[ResourceSlot], well_names: list[str], liquid_names: list[str], volumes: list[float]
|
||||
) -> SetLiquidFromPlateReturn:
|
||||
return super().set_liquid_from_plate(plate, well_names, liquid_names, volumes)
|
||||
|
||||
@@ -798,7 +709,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
touch_tip: bool = False,
|
||||
liquid_height: Optional[List[Optional[float]]] = None,
|
||||
blow_out_air_volume: Optional[List[Optional[float]]] = None,
|
||||
blow_out_air_volume_before: Optional[List[Optional[float]]] = None,
|
||||
spread: Literal["wide", "tight", "custom"] = "wide",
|
||||
is_96_well: bool = False,
|
||||
mix_stage: Optional[Literal["none", "before", "after", "both"]] = "none",
|
||||
@@ -809,9 +719,7 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
delays: Optional[List[int]] = None,
|
||||
none_keys: List[str] = [],
|
||||
) -> TransferLiquidReturn:
|
||||
if self.step_mode:
|
||||
await self.create_protocol(f"transfer_liquid{time.time()}")
|
||||
res = await super().transfer_liquid(
|
||||
return await super().transfer_liquid(
|
||||
sources,
|
||||
targets,
|
||||
tip_racks,
|
||||
@@ -824,7 +732,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
touch_tip=touch_tip,
|
||||
liquid_height=liquid_height,
|
||||
blow_out_air_volume=blow_out_air_volume,
|
||||
blow_out_air_volume_before=blow_out_air_volume_before,
|
||||
spread=spread,
|
||||
is_96_well=is_96_well,
|
||||
mix_stage=mix_stage,
|
||||
@@ -835,9 +742,6 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
delays=delays,
|
||||
none_keys=none_keys,
|
||||
)
|
||||
if self.step_mode:
|
||||
await self.run_protocol()
|
||||
return res
|
||||
|
||||
async def custom_delay(self, seconds=0, msg=None):
|
||||
return await super().custom_delay(seconds, msg)
|
||||
@@ -854,10 +758,9 @@ class PRCXI9300Handler(LiquidHandlerAbstract):
|
||||
offsets: Optional[Coordinate] = None,
|
||||
mix_rate: Optional[float] = None,
|
||||
none_keys: List[str] = [],
|
||||
use_channels: Optional[List[int]] = [0],
|
||||
):
|
||||
return await self._unilabos_backend.mix(
|
||||
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys, use_channels
|
||||
targets, mix_time, mix_vol, height_to_bottom, offsets, mix_rate, none_keys
|
||||
)
|
||||
|
||||
def iter_tips(self, tip_racks: Sequence[TipRack]) -> Iterator[Resource]:
|
||||
@@ -1286,15 +1189,9 @@ class PRCXI9300Backend(LiquidHandlerBackend):
|
||||
offsets: Optional[Coordinate] = None,
|
||||
mix_rate: Optional[float] = None,
|
||||
none_keys: List[str] = [],
|
||||
use_channels: Optional[List[int]] = [0],
|
||||
):
|
||||
"""Mix liquid in the specified resources."""
|
||||
if use_channels == [0]:
|
||||
axis = "Left"
|
||||
elif use_channels == [1]:
|
||||
axis = "Right"
|
||||
else:
|
||||
raise ValueError("Invalid use channels: " + str(use_channels))
|
||||
|
||||
plate_indexes = []
|
||||
for op in targets:
|
||||
deck = op.parent.parent.parent
|
||||
|
||||
@@ -59,7 +59,6 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.total_height = total_height
|
||||
self.joint_config = kwargs.get("joint_config", None)
|
||||
self.lh_device_id = kwargs.get("lh_device_id", "lh_joint_publisher")
|
||||
self.simulate_rviz = kwargs.get("simulate_rviz", False)
|
||||
if not rclpy.ok():
|
||||
rclpy.init()
|
||||
self.joint_state_publisher = None
|
||||
@@ -70,7 +69,7 @@ class UniLiquidHandlerRvizBackend(LiquidHandlerBackend):
|
||||
self.joint_state_publisher = LiquidHandlerJointPublisher(
|
||||
joint_config=self.joint_config,
|
||||
lh_device_id=self.lh_device_id,
|
||||
simulate_rviz=self.simulate_rviz)
|
||||
simulate_rviz=True)
|
||||
|
||||
# 启动ROS executor
|
||||
self.executor = rclpy.executors.MultiThreadedExecutor()
|
||||
|
||||
376
unilabos/devices/motor/ZDT_X42.py
Normal file
376
unilabos/devices/motor/ZDT_X42.py
Normal 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()
|
||||
@@ -19,11 +19,10 @@ from rclpy.node import Node
|
||||
import re
|
||||
|
||||
class LiquidHandlerJointPublisher(BaseROS2DeviceNode):
|
||||
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", registry_name: str = "lh_joint_publisher", **kwargs):
|
||||
def __init__(self,resources_config:list, resource_tracker, rate=50, device_id:str = "lh_joint_publisher", **kwargs):
|
||||
super().__init__(
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
hardware_interface={},
|
||||
|
||||
@@ -42,7 +42,6 @@ class LiquidHandlerJointPublisher(Node):
|
||||
while self.resource_action is None:
|
||||
self.resource_action = self.check_tf_update_actions()
|
||||
time.sleep(1)
|
||||
self.get_logger().info(f'Waiting for TfUpdate server: {self.resource_action}')
|
||||
|
||||
self.resource_action_client = ActionClient(self, SendCmd, self.resource_action)
|
||||
while not self.resource_action_client.wait_for_server(timeout_sec=1.0):
|
||||
|
||||
@@ -623,6 +623,119 @@ class ChinweDevice(UniversalDriver):
|
||||
time.sleep(duration)
|
||||
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:
|
||||
"""支持标准 JSON 指令调用"""
|
||||
return super().execute_command_from_outer(command_dict)
|
||||
|
||||
379
unilabos/devices/separator/xkc_sensor.py
Normal file
379
unilabos/devices/separator/xkc_sensor.py
Normal 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")
|
||||
@@ -15,35 +15,35 @@ class VirtualPumpMode(Enum):
|
||||
|
||||
class VirtualTransferPump:
|
||||
"""虚拟转移泵类 - 模拟泵的基本功能,无需实际硬件 🚰"""
|
||||
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
|
||||
def __init__(self, device_id: str = None, config: dict = None, **kwargs):
|
||||
"""
|
||||
初始化虚拟转移泵
|
||||
|
||||
|
||||
Args:
|
||||
device_id: 设备ID
|
||||
config: 配置字典,包含max_volume, port等参数
|
||||
**kwargs: 其他参数,确保兼容性
|
||||
"""
|
||||
self.device_id = device_id or "virtual_transfer_pump"
|
||||
|
||||
|
||||
# 从config或kwargs中获取参数,确保类型正确
|
||||
if config:
|
||||
self.max_volume = float(config.get("max_volume", 25.0))
|
||||
self.port = config.get("port", "VIRTUAL")
|
||||
self.max_volume = float(config.get('max_volume', 25.0))
|
||||
self.port = config.get('port', 'VIRTUAL')
|
||||
else:
|
||||
self.max_volume = float(kwargs.get("max_volume", 25.0))
|
||||
self.port = kwargs.get("port", "VIRTUAL")
|
||||
|
||||
self._transfer_rate = float(kwargs.get("transfer_rate", 0))
|
||||
self.mode = kwargs.get("mode", VirtualPumpMode.Normal)
|
||||
|
||||
self.max_volume = float(kwargs.get('max_volume', 25.0))
|
||||
self.port = kwargs.get('port', 'VIRTUAL')
|
||||
|
||||
self._transfer_rate = float(kwargs.get('transfer_rate', 0))
|
||||
self.mode = kwargs.get('mode', VirtualPumpMode.Normal)
|
||||
|
||||
# 状态变量 - 确保都是正确类型
|
||||
self._status = "Idle"
|
||||
self._position = 0.0 # float
|
||||
self._max_velocity = 5.0 # float
|
||||
self._max_velocity = 5.0 # float
|
||||
self._current_volume = 0.0 # float
|
||||
|
||||
# 🚀 新增:快速模式设置 - 大幅缩短执行时间
|
||||
@@ -52,16 +52,14 @@ class VirtualTransferPump:
|
||||
self._fast_dispense_time = 1.0 # 快速喷射时间(秒)
|
||||
|
||||
self.logger = logging.getLogger(f"VirtualTransferPump.{self.device_id}")
|
||||
|
||||
|
||||
print(f"🚰 === 虚拟转移泵 {self.device_id} 已创建 === ✨")
|
||||
print(
|
||||
f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s"
|
||||
)
|
||||
print(f"💨 快速模式: {'启用' if self._fast_mode else '禁用'} | 移动时间: {self._fast_move_time}s | 喷射时间: {self._fast_dispense_time}s")
|
||||
print(f"📊 最大容量: {self.max_volume}mL | 端口: {self.port}")
|
||||
|
||||
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
self._ros_node = ros_node
|
||||
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""初始化虚拟泵 🚀"""
|
||||
self.logger.info(f"🔧 初始化虚拟转移泵 {self.device_id} ✨")
|
||||
@@ -70,33 +68,33 @@ class VirtualTransferPump:
|
||||
self._current_volume = 0.0
|
||||
self.logger.info(f"✅ 转移泵 {self.device_id} 初始化完成 🚰")
|
||||
return True
|
||||
|
||||
|
||||
async def cleanup(self) -> bool:
|
||||
"""清理虚拟泵 🧹"""
|
||||
self.logger.info(f"🧹 清理虚拟转移泵 {self.device_id} 🔚")
|
||||
self._status = "Idle"
|
||||
self.logger.info(f"✅ 转移泵 {self.device_id} 清理完成 💤")
|
||||
return True
|
||||
|
||||
|
||||
# 基本属性
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
|
||||
@property
|
||||
def position(self) -> float:
|
||||
"""当前柱塞位置 (ml) 📍"""
|
||||
return self._position
|
||||
|
||||
|
||||
@property
|
||||
def current_volume(self) -> float:
|
||||
"""当前注射器中的体积 (ml) 💧"""
|
||||
return self._current_volume
|
||||
|
||||
|
||||
@property
|
||||
def max_velocity(self) -> float:
|
||||
return self._max_velocity
|
||||
|
||||
|
||||
@property
|
||||
def transfer_rate(self) -> float:
|
||||
return self._transfer_rate
|
||||
@@ -105,17 +103,17 @@ class VirtualTransferPump:
|
||||
"""设置最大速度 (ml/s) 🌊"""
|
||||
self._max_velocity = max(0.1, min(50.0, velocity)) # 限制在合理范围内
|
||||
self.logger.info(f"🌊 设置最大速度为 {self._max_velocity} mL/s")
|
||||
|
||||
|
||||
def get_status(self) -> str:
|
||||
"""获取泵状态 📋"""
|
||||
return self._status
|
||||
|
||||
|
||||
async def _simulate_operation(self, duration: float):
|
||||
"""模拟操作延时 ⏱️"""
|
||||
self._status = "Busy"
|
||||
await self._ros_node.sleep(duration)
|
||||
self._status = "Idle"
|
||||
|
||||
|
||||
def _calculate_duration(self, volume: float, velocity: float = None) -> float:
|
||||
"""
|
||||
计算操作持续时间 ⏰
|
||||
@@ -123,10 +121,10 @@ class VirtualTransferPump:
|
||||
"""
|
||||
if velocity is None:
|
||||
velocity = self._max_velocity
|
||||
|
||||
|
||||
# 📊 计算理论时间(用于日志显示)
|
||||
theoretical_duration = abs(volume) / velocity
|
||||
|
||||
|
||||
# 🚀 如果启用快速模式,使用固定的快速时间
|
||||
if self._fast_mode:
|
||||
# 根据操作类型选择快速时间
|
||||
@@ -134,13 +132,13 @@ class VirtualTransferPump:
|
||||
actual_duration = self._fast_move_time
|
||||
else: # 很小的操作
|
||||
actual_duration = 0.5
|
||||
|
||||
|
||||
self.logger.debug(f"⚡ 快速模式: 理论时间 {theoretical_duration:.2f}s → 实际时间 {actual_duration:.2f}s")
|
||||
return actual_duration
|
||||
else:
|
||||
# 正常模式使用理论时间
|
||||
return theoretical_duration
|
||||
|
||||
|
||||
def _calculate_display_duration(self, volume: float, velocity: float = None) -> float:
|
||||
"""
|
||||
计算显示用的持续时间(用于日志) 📊
|
||||
@@ -149,16 +147,16 @@ class VirtualTransferPump:
|
||||
if velocity is None:
|
||||
velocity = self._max_velocity
|
||||
return abs(volume) / velocity
|
||||
|
||||
|
||||
# 新的set_position方法 - 专门用于SetPumpPosition动作
|
||||
async def set_position(self, position: float, max_velocity: float = None):
|
||||
"""
|
||||
移动到绝对位置 - 专门用于SetPumpPosition动作 🎯
|
||||
|
||||
|
||||
Args:
|
||||
position (float): 目标位置 (ml)
|
||||
max_velocity (float): 移动速度 (ml/s)
|
||||
|
||||
|
||||
Returns:
|
||||
dict: 符合SetPumpPosition.action定义的结果
|
||||
"""
|
||||
@@ -166,19 +164,19 @@ class VirtualTransferPump:
|
||||
# 验证并转换参数
|
||||
target_position = float(position)
|
||||
velocity = float(max_velocity) if max_velocity is not None else self._max_velocity
|
||||
|
||||
|
||||
# 限制位置在有效范围内
|
||||
target_position = max(0.0, min(float(self.max_volume), target_position))
|
||||
|
||||
|
||||
# 计算移动距离
|
||||
volume_to_move = abs(target_position - self._position)
|
||||
|
||||
|
||||
# 📊 计算显示用的时间(用于日志)
|
||||
display_duration = self._calculate_display_duration(volume_to_move, velocity)
|
||||
|
||||
|
||||
# ⚡ 计算实际执行时间(快速模式)
|
||||
actual_duration = self._calculate_duration(volume_to_move, velocity)
|
||||
|
||||
|
||||
# 🎯 确定操作类型和emoji
|
||||
if target_position > self._position:
|
||||
operation_type = "吸液"
|
||||
@@ -189,34 +187,28 @@ class VirtualTransferPump:
|
||||
else:
|
||||
operation_type = "保持"
|
||||
operation_emoji = "📍"
|
||||
|
||||
|
||||
self.logger.info(f"🎯 SET_POSITION: {operation_type} {operation_emoji}")
|
||||
self.logger.info(
|
||||
f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)"
|
||||
)
|
||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {target_position:.2f}mL (移动 {volume_to_move:.2f}mL)")
|
||||
self.logger.info(f" 🌊 速度: {velocity:.2f} mL/s")
|
||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||
|
||||
|
||||
if self._fast_mode:
|
||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||
|
||||
|
||||
# 🚀 模拟移动过程
|
||||
if volume_to_move > 0.01: # 只有当移动距离足够大时才显示进度
|
||||
start_position = self._position
|
||||
steps = 5 if actual_duration > 0.5 else 2 # 根据实际时间调整步数
|
||||
step_duration = actual_duration / steps
|
||||
|
||||
|
||||
self.logger.info(f"🚀 开始{operation_type}... {operation_emoji}")
|
||||
|
||||
|
||||
for i in range(steps + 1):
|
||||
# 计算当前位置和进度
|
||||
progress = (i / steps) * 100 if steps > 0 else 100
|
||||
current_pos = (
|
||||
start_position + (target_position - start_position) * (i / steps)
|
||||
if steps > 0
|
||||
else target_position
|
||||
)
|
||||
|
||||
current_pos = start_position + (target_position - start_position) * (i / steps) if steps > 0 else target_position
|
||||
|
||||
# 更新状态
|
||||
if i < steps:
|
||||
self._status = f"{operation_type}中"
|
||||
@@ -224,10 +216,10 @@ class VirtualTransferPump:
|
||||
else:
|
||||
self._status = "Idle"
|
||||
status_emoji = "✅"
|
||||
|
||||
|
||||
self._position = current_pos
|
||||
self._current_volume = current_pos
|
||||
|
||||
|
||||
# 显示进度(每25%或最后一步)
|
||||
if i == 0:
|
||||
self.logger.debug(f" 🔄 {operation_type}开始: {progress:.0f}%")
|
||||
@@ -235,7 +227,7 @@ class VirtualTransferPump:
|
||||
self.logger.debug(f" 🔄 {operation_type}进度: {progress:.0f}%")
|
||||
elif i == steps:
|
||||
self.logger.info(f" ✅ {operation_type}完成: {progress:.0f}% | 当前位置: {current_pos:.2f}mL")
|
||||
|
||||
|
||||
# 等待一小步时间
|
||||
if i < steps and step_duration > 0:
|
||||
await self._ros_node.sleep(step_duration)
|
||||
@@ -244,27 +236,25 @@ class VirtualTransferPump:
|
||||
self._position = target_position
|
||||
self._current_volume = target_position
|
||||
self.logger.info(f" 📍 微调完成: {target_position:.2f}mL")
|
||||
|
||||
|
||||
# 确保最终位置准确
|
||||
self._position = target_position
|
||||
self._current_volume = target_position
|
||||
self._status = "Idle"
|
||||
|
||||
|
||||
# 📊 最终状态日志
|
||||
if volume_to_move > 0.01:
|
||||
self.logger.info(
|
||||
f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL"
|
||||
)
|
||||
|
||||
self.logger.info(f"🎉 SET_POSITION 完成! 📍 最终位置: {self._position:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||
|
||||
# 返回符合action定义的结果
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"✅ 成功移动到位置 {self._position:.2f}mL ({operation_type})",
|
||||
"final_position": self._position,
|
||||
"final_volume": self._current_volume,
|
||||
"operation_type": operation_type,
|
||||
"operation_type": operation_type
|
||||
}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ 设置位置失败: {str(e)}"
|
||||
self.logger.error(error_msg)
|
||||
@@ -272,136 +262,134 @@ class VirtualTransferPump:
|
||||
"success": False,
|
||||
"message": error_msg,
|
||||
"final_position": self._position,
|
||||
"final_volume": self._current_volume,
|
||||
"final_volume": self._current_volume
|
||||
}
|
||||
|
||||
|
||||
# 其他泵操作方法
|
||||
async def pull_plunger(self, volume: float, velocity: float = None):
|
||||
"""
|
||||
拉取柱塞(吸液) 📥
|
||||
|
||||
|
||||
Args:
|
||||
volume (float): 要拉取的体积 (ml)
|
||||
velocity (float): 拉取速度 (ml/s)
|
||||
"""
|
||||
new_position = min(self.max_volume, self._position + volume)
|
||||
actual_volume = new_position - self._position
|
||||
|
||||
|
||||
if actual_volume <= 0:
|
||||
self.logger.warning("⚠️ 无法吸液 - 已达到最大容量")
|
||||
return
|
||||
|
||||
|
||||
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
||||
actual_duration = self._calculate_duration(actual_volume, velocity)
|
||||
|
||||
|
||||
self.logger.info(f"📥 开始吸液: {actual_volume:.2f}mL")
|
||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||
|
||||
|
||||
if self._fast_mode:
|
||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||
|
||||
|
||||
await self._simulate_operation(actual_duration)
|
||||
|
||||
|
||||
self._position = new_position
|
||||
self._current_volume = new_position
|
||||
|
||||
|
||||
self.logger.info(f"✅ 吸液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||
|
||||
async def push_plunger(self, volume: float, velocity: float = None):
|
||||
"""
|
||||
推出柱塞(排液) 📤
|
||||
|
||||
|
||||
Args:
|
||||
volume (float): 要推出的体积 (ml)
|
||||
velocity (float): 推出速度 (ml/s)
|
||||
"""
|
||||
new_position = max(0, self._position - volume)
|
||||
actual_volume = self._position - new_position
|
||||
|
||||
|
||||
if actual_volume <= 0:
|
||||
self.logger.warning("⚠️ 无法排液 - 已达到最小容量")
|
||||
return
|
||||
|
||||
|
||||
display_duration = self._calculate_display_duration(actual_volume, velocity)
|
||||
actual_duration = self._calculate_duration(actual_volume, velocity)
|
||||
|
||||
|
||||
self.logger.info(f"📤 开始排液: {actual_volume:.2f}mL")
|
||||
self.logger.info(f" 📍 位置: {self._position:.2f}mL → {new_position:.2f}mL")
|
||||
self.logger.info(f" ⏰ 预计时间: {display_duration:.2f}s")
|
||||
|
||||
|
||||
if self._fast_mode:
|
||||
self.logger.info(f" ⚡ 快速模式: 实际用时 {actual_duration:.2f}s")
|
||||
|
||||
|
||||
await self._simulate_operation(actual_duration)
|
||||
|
||||
|
||||
self._position = new_position
|
||||
self._current_volume = new_position
|
||||
|
||||
|
||||
self.logger.info(f"✅ 排液完成: {actual_volume:.2f}mL | 💧 当前体积: {self._current_volume:.2f}mL")
|
||||
|
||||
# 便捷操作方法
|
||||
async def aspirate(self, volume: float, velocity: float = None):
|
||||
"""吸液操作 📥"""
|
||||
await self.pull_plunger(volume, velocity)
|
||||
|
||||
|
||||
async def dispense(self, volume: float, velocity: float = None):
|
||||
"""排液操作 📤"""
|
||||
await self.push_plunger(volume, velocity)
|
||||
|
||||
|
||||
async def transfer(self, volume: float, aspirate_velocity: float = None, dispense_velocity: float = None):
|
||||
"""转移操作(先吸后排) 🔄"""
|
||||
self.logger.info(f"🔄 开始转移操作: {volume:.2f}mL")
|
||||
|
||||
|
||||
# 吸液
|
||||
await self.aspirate(volume, aspirate_velocity)
|
||||
|
||||
|
||||
# 短暂停顿
|
||||
self.logger.debug("⏸️ 短暂停顿...")
|
||||
await self._ros_node.sleep(0.1)
|
||||
|
||||
|
||||
# 排液
|
||||
await self.dispense(volume, dispense_velocity)
|
||||
|
||||
|
||||
async def empty_syringe(self, velocity: float = None):
|
||||
"""清空注射器"""
|
||||
await self.set_position(0, velocity)
|
||||
|
||||
|
||||
async def fill_syringe(self, velocity: float = None):
|
||||
"""充满注射器"""
|
||||
await self.set_position(self.max_volume, velocity)
|
||||
|
||||
|
||||
async def stop_operation(self):
|
||||
"""停止当前操作"""
|
||||
self._status = "Idle"
|
||||
self.logger.info("Operation stopped")
|
||||
|
||||
|
||||
# 状态查询方法
|
||||
def get_position(self) -> float:
|
||||
"""获取当前位置"""
|
||||
return self._position
|
||||
|
||||
|
||||
def get_current_volume(self) -> float:
|
||||
"""获取当前体积"""
|
||||
return self._current_volume
|
||||
|
||||
|
||||
def get_remaining_capacity(self) -> float:
|
||||
"""获取剩余容量"""
|
||||
return self.max_volume - self._current_volume
|
||||
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""检查是否为空"""
|
||||
return self._current_volume <= 0.01 # 允许小量误差
|
||||
|
||||
|
||||
def is_full(self) -> bool:
|
||||
"""检查是否已满"""
|
||||
return self._current_volume >= (self.max_volume - 0.01) # 允许小量误差
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
||||
)
|
||||
|
||||
return f"VirtualTransferPump({self.device_id}: {self._current_volume:.2f}/{self.max_volume} ml, {self._status})"
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
@@ -410,20 +398,20 @@ class VirtualTransferPump:
|
||||
async def demo():
|
||||
"""虚拟泵使用示例"""
|
||||
pump = VirtualTransferPump("demo_pump", {"max_volume": 50.0})
|
||||
|
||||
|
||||
await pump.initialize()
|
||||
|
||||
|
||||
print(f"Initial state: {pump}")
|
||||
|
||||
|
||||
# 测试set_position方法
|
||||
result = await pump.set_position(10.0, max_velocity=2.0)
|
||||
print(f"Set position result: {result}")
|
||||
print(f"After setting position to 10ml: {pump}")
|
||||
|
||||
|
||||
# 吸液测试
|
||||
await pump.aspirate(5.0, velocity=2.0)
|
||||
print(f"After aspirating 5ml: {pump}")
|
||||
|
||||
|
||||
# 清空测试
|
||||
result = await pump.set_position(0.0)
|
||||
print(f"Empty result: {result}")
|
||||
|
||||
@@ -11,10 +11,9 @@ Virtual Workbench Device - 模拟工作台设备
|
||||
|
||||
注意:调用来自线程池,使用 threading.Lock 进行同步
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from threading import Lock, RLock
|
||||
@@ -22,47 +21,38 @@ from threading import Lock, RLock
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
from unilabos.utils.decorator import not_action, always_free
|
||||
from unilabos.resources.resource_tracker import SampleUUIDsType, LabSample, RETURN_UNILABOS_SAMPLES
|
||||
from unilabos.utils.decorator import not_action
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
|
||||
|
||||
class MoveToHeatingStationResult(TypedDict):
|
||||
"""move_to_heating_station 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class StartHeatingResult(TypedDict):
|
||||
"""start_heating 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class MoveToOutputResult(TypedDict):
|
||||
"""move_to_output 返回类型"""
|
||||
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
class PrepareMaterialsResult(TypedDict):
|
||||
"""prepare_materials 返回类型 - 批量准备物料"""
|
||||
|
||||
success: bool
|
||||
count: int
|
||||
material_1: int # 物料编号1
|
||||
@@ -71,15 +61,12 @@ class PrepareMaterialsResult(TypedDict):
|
||||
material_4: int # 物料编号4
|
||||
material_5: int # 物料编号5
|
||||
message: str
|
||||
unilabos_samples: List[LabSample]
|
||||
|
||||
|
||||
# ============ 状态枚举 ============
|
||||
|
||||
|
||||
class HeatingStationState(Enum):
|
||||
"""加热台状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
OCCUPIED = "occupied" # 已放置物料,等待加热
|
||||
HEATING = "heating" # 加热中
|
||||
@@ -88,7 +75,6 @@ class HeatingStationState(Enum):
|
||||
|
||||
class ArmState(Enum):
|
||||
"""机械臂状态枚举"""
|
||||
|
||||
IDLE = "idle" # 空闲
|
||||
BUSY = "busy" # 工作中
|
||||
|
||||
@@ -96,7 +82,6 @@ class ArmState(Enum):
|
||||
@dataclass
|
||||
class HeatingStation:
|
||||
"""加热台数据结构"""
|
||||
|
||||
station_id: int
|
||||
state: HeatingStationState = HeatingStationState.IDLE
|
||||
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
|
||||
@@ -123,8 +108,8 @@ class VirtualWorkbench:
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
# 配置常量
|
||||
ARM_OPERATION_TIME: float = 2 # 机械臂操作时间(秒)
|
||||
HEATING_TIME: float = 60.0 # 加热时间(秒)
|
||||
ARM_OPERATION_TIME: float = 3.0 # 机械臂操作时间(秒)
|
||||
HEATING_TIME: float = 10.0 # 加热时间(秒)
|
||||
NUM_HEATING_STATIONS: int = 3 # 加热台数量
|
||||
|
||||
def __init__(self, device_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
@@ -141,9 +126,9 @@ class VirtualWorkbench:
|
||||
self.data: Dict[str, Any] = {}
|
||||
|
||||
# 从config中获取可配置参数
|
||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", self.ARM_OPERATION_TIME))
|
||||
self.HEATING_TIME = float(self.config.get("heating_time", self.HEATING_TIME))
|
||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", self.NUM_HEATING_STATIONS))
|
||||
self.ARM_OPERATION_TIME = float(self.config.get("arm_operation_time", 3.0))
|
||||
self.HEATING_TIME = float(self.config.get("heating_time", 10.0))
|
||||
self.NUM_HEATING_STATIONS = int(self.config.get("num_heating_stations", 3))
|
||||
|
||||
# 机械臂状态和锁 (使用threading.Lock)
|
||||
self._arm_lock = Lock()
|
||||
@@ -152,7 +137,8 @@ class VirtualWorkbench:
|
||||
|
||||
# 加热台状态 (station_id -> HeatingStation) - 立即初始化,不依赖initialize()
|
||||
self._heating_stations: Dict[int, HeatingStation] = {
|
||||
i: HeatingStation(station_id=i) for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
i: HeatingStation(station_id=i)
|
||||
for i in range(1, self.NUM_HEATING_STATIONS + 1)
|
||||
}
|
||||
self._stations_lock = RLock() # 可重入锁,保护加热台状态
|
||||
|
||||
@@ -192,16 +178,14 @@ class VirtualWorkbench:
|
||||
station.heating_progress = 0.0
|
||||
|
||||
# 初始化状态
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Ready",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"arm_current_task": None,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": 0,
|
||||
"message": "工作台就绪",
|
||||
}
|
||||
)
|
||||
self.data.update({
|
||||
"status": "Ready",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"arm_current_task": None,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": 0,
|
||||
"message": "工作台就绪",
|
||||
})
|
||||
|
||||
self.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
|
||||
return True
|
||||
@@ -220,14 +204,12 @@ class VirtualWorkbench:
|
||||
with self._tasks_lock:
|
||||
self._active_tasks.clear()
|
||||
|
||||
self.data.update(
|
||||
{
|
||||
"status": "Offline",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"heating_stations": {},
|
||||
"message": "工作台已关闭",
|
||||
}
|
||||
)
|
||||
self.data.update({
|
||||
"status": "Offline",
|
||||
"arm_state": ArmState.IDLE.value,
|
||||
"heating_stations": {},
|
||||
"message": "工作台已关闭",
|
||||
})
|
||||
return True
|
||||
|
||||
def _get_stations_status(self) -> Dict[int, Dict[str, Any]]:
|
||||
@@ -245,14 +227,12 @@ class VirtualWorkbench:
|
||||
|
||||
def _update_data_status(self, message: Optional[str] = None):
|
||||
"""更新状态数据"""
|
||||
self.data.update(
|
||||
{
|
||||
"arm_state": self._arm_state.value,
|
||||
"arm_current_task": self._arm_current_task,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": len(self._active_tasks),
|
||||
}
|
||||
)
|
||||
self.data.update({
|
||||
"arm_state": self._arm_state.value,
|
||||
"arm_current_task": self._arm_current_task,
|
||||
"heating_stations": self._get_stations_status(),
|
||||
"active_tasks_count": len(self._active_tasks),
|
||||
})
|
||||
if message:
|
||||
self.data["message"] = message
|
||||
|
||||
@@ -300,7 +280,6 @@ class VirtualWorkbench:
|
||||
|
||||
def prepare_materials(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
count: int = 5,
|
||||
) -> PrepareMaterialsResult:
|
||||
"""
|
||||
@@ -318,7 +297,10 @@ class VirtualWorkbench:
|
||||
# 生成物料列表 A1 - A{count}
|
||||
materials = [i for i in range(1, count + 1)]
|
||||
|
||||
self.logger.info(f"[准备物料] 生成 {count} 个物料: " f"A1-A{count} -> material_1~material_{count}")
|
||||
self.logger.info(
|
||||
f"[准备物料] 生成 {count} 个物料: "
|
||||
f"A1-A{count} -> material_1~material_{count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -329,12 +311,10 @@ class VirtualWorkbench:
|
||||
"material_4": materials[3] if len(materials) > 3 else 0,
|
||||
"material_5": materials[4] if len(materials) > 4 else 0,
|
||||
"message": f"已准备 {count} 个物料: A1-A{count}",
|
||||
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
def move_to_heating_station(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
material_number: int,
|
||||
) -> MoveToHeatingStationResult:
|
||||
"""
|
||||
@@ -411,9 +391,6 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"{material_id}已成功移动到加热台{station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -426,15 +403,10 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
@always_free
|
||||
def start_heating(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> StartHeatingResult:
|
||||
@@ -457,9 +429,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -472,9 +441,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
if station.state == HeatingStationState.HEATING:
|
||||
@@ -484,9 +450,6 @@ class VirtualWorkbench:
|
||||
"material_id": station.current_material,
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}已经在加热中",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
material_id = station.current_material
|
||||
@@ -502,21 +465,10 @@ class VirtualWorkbench:
|
||||
|
||||
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
||||
|
||||
# 打印当前所有正在加热的台位
|
||||
with self._stations_lock:
|
||||
heating_list = [
|
||||
f"加热台{sid}:{s.current_material}"
|
||||
for sid, s in self._heating_stations.items()
|
||||
if s.state == HeatingStationState.HEATING and s.current_material
|
||||
]
|
||||
self.logger.info(f"[并行加热] 当前同时加热中: {', '.join(heating_list)}")
|
||||
|
||||
# 模拟加热过程
|
||||
# 模拟加热过程 (10秒)
|
||||
start_time = time.time()
|
||||
last_countdown_log = start_time
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
remaining = max(0.0, self.HEATING_TIME - elapsed)
|
||||
progress = min(100.0, (elapsed / self.HEATING_TIME) * 100)
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -524,11 +476,6 @@ class VirtualWorkbench:
|
||||
|
||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||
|
||||
# 每5秒打印一次倒计时
|
||||
if time.time() - last_countdown_log >= 5.0:
|
||||
self.logger.info(f"[加热台{station_id}] {material_id} 剩余 {remaining:.1f}s")
|
||||
last_countdown_log = time.time()
|
||||
|
||||
if elapsed >= self.HEATING_TIME:
|
||||
break
|
||||
|
||||
@@ -552,14 +499,10 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}加热完成",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
def move_to_output(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> MoveToOutputResult:
|
||||
@@ -582,9 +525,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
@@ -598,9 +538,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
if station.state != HeatingStationState.COMPLETED:
|
||||
@@ -610,9 +547,6 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
output_position = f"C{output_number}"
|
||||
@@ -661,9 +595,6 @@ class VirtualWorkbench:
|
||||
"material_id": material_id,
|
||||
"output_position": output_position,
|
||||
"message": f"{material_id}已成功移动到{output_position}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
@@ -676,9 +607,6 @@ class VirtualWorkbench:
|
||||
"material_id": "",
|
||||
"output_position": output_position,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
"unilabos_samples": [
|
||||
LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for
|
||||
sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
# ============ 状态属性 ============
|
||||
|
||||
@@ -258,7 +258,7 @@ class BioyondResourceSynchronizer(ResourceSynchronizer):
|
||||
logger.info(f"[同步→Bioyond] ➕ 物料不存在于 Bioyond,将创建新物料并入库")
|
||||
|
||||
# 第1步:从配置中获取仓库配置
|
||||
warehouse_mapping = self.bioyond_config.get("warehouse_mapping", {})
|
||||
warehouse_mapping = self.workstation.bioyond_config.get("warehouse_mapping", {})
|
||||
|
||||
# 确定目标仓库名称
|
||||
parent_name = None
|
||||
|
||||
@@ -96,13 +96,10 @@ serial:
|
||||
type: string
|
||||
port:
|
||||
type: string
|
||||
registry_name:
|
||||
type: string
|
||||
resource_tracker:
|
||||
type: object
|
||||
required:
|
||||
- device_id
|
||||
- registry_name
|
||||
- port
|
||||
type: object
|
||||
data:
|
||||
|
||||
@@ -67,9 +67,6 @@ camera:
|
||||
period:
|
||||
default: 0.1
|
||||
type: number
|
||||
registry_name:
|
||||
default: ''
|
||||
type: string
|
||||
resource_tracker:
|
||||
type: object
|
||||
required: []
|
||||
|
||||
@@ -317,6 +317,47 @@ separator.chinwe:
|
||||
- port
|
||||
type: object
|
||||
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:
|
||||
goal:
|
||||
target_state: 有液
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
286
unilabos/registry/devices/motor.yaml
Normal file
286
unilabos/registry/devices/motor.yaml
Normal 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
|
||||
148
unilabos/registry/devices/sensor.yaml
Normal file
148
unilabos/registry/devices/sensor.yaml
Normal 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
|
||||
@@ -5835,25 +5835,6 @@ virtual_workbench:
|
||||
- material_number
|
||||
type: object
|
||||
result:
|
||||
$defs:
|
||||
LabSample:
|
||||
properties:
|
||||
extra:
|
||||
additionalProperties: true
|
||||
title: Extra
|
||||
type: object
|
||||
oss_path:
|
||||
title: Oss Path
|
||||
type: string
|
||||
sample_uuid:
|
||||
title: Sample Uuid
|
||||
type: string
|
||||
required:
|
||||
- sample_uuid
|
||||
- oss_path
|
||||
- extra
|
||||
title: LabSample
|
||||
type: object
|
||||
description: move_to_heating_station 返回类型
|
||||
properties:
|
||||
material_id:
|
||||
@@ -5872,18 +5853,12 @@ virtual_workbench:
|
||||
success:
|
||||
title: Success
|
||||
type: boolean
|
||||
unilabos_samples:
|
||||
items:
|
||||
$ref: '#/$defs/LabSample'
|
||||
title: Unilabos Samples
|
||||
type: array
|
||||
required:
|
||||
- success
|
||||
- station_id
|
||||
- material_id
|
||||
- material_number
|
||||
- message
|
||||
- unilabos_samples
|
||||
title: MoveToHeatingStationResult
|
||||
type: object
|
||||
required:
|
||||
@@ -5928,25 +5903,6 @@ virtual_workbench:
|
||||
- material_number
|
||||
type: object
|
||||
result:
|
||||
$defs:
|
||||
LabSample:
|
||||
properties:
|
||||
extra:
|
||||
additionalProperties: true
|
||||
title: Extra
|
||||
type: object
|
||||
oss_path:
|
||||
title: Oss Path
|
||||
type: string
|
||||
sample_uuid:
|
||||
title: Sample Uuid
|
||||
type: string
|
||||
required:
|
||||
- sample_uuid
|
||||
- oss_path
|
||||
- extra
|
||||
title: LabSample
|
||||
type: object
|
||||
description: move_to_output 返回类型
|
||||
properties:
|
||||
material_id:
|
||||
@@ -5958,16 +5914,10 @@ virtual_workbench:
|
||||
success:
|
||||
title: Success
|
||||
type: boolean
|
||||
unilabos_samples:
|
||||
items:
|
||||
$ref: '#/$defs/LabSample'
|
||||
title: Unilabos Samples
|
||||
type: array
|
||||
required:
|
||||
- success
|
||||
- station_id
|
||||
- material_id
|
||||
- unilabos_samples
|
||||
title: MoveToOutputResult
|
||||
type: object
|
||||
required:
|
||||
@@ -6022,25 +5972,6 @@ virtual_workbench:
|
||||
required: []
|
||||
type: object
|
||||
result:
|
||||
$defs:
|
||||
LabSample:
|
||||
properties:
|
||||
extra:
|
||||
additionalProperties: true
|
||||
title: Extra
|
||||
type: object
|
||||
oss_path:
|
||||
title: Oss Path
|
||||
type: string
|
||||
sample_uuid:
|
||||
title: Sample Uuid
|
||||
type: string
|
||||
required:
|
||||
- sample_uuid
|
||||
- oss_path
|
||||
- extra
|
||||
title: LabSample
|
||||
type: object
|
||||
description: prepare_materials 返回类型 - 批量准备物料
|
||||
properties:
|
||||
count:
|
||||
@@ -6067,11 +5998,6 @@ virtual_workbench:
|
||||
success:
|
||||
title: Success
|
||||
type: boolean
|
||||
unilabos_samples:
|
||||
items:
|
||||
$ref: '#/$defs/LabSample'
|
||||
title: Unilabos Samples
|
||||
type: array
|
||||
required:
|
||||
- success
|
||||
- count
|
||||
@@ -6081,7 +6007,6 @@ virtual_workbench:
|
||||
- material_4
|
||||
- material_5
|
||||
- message
|
||||
- unilabos_samples
|
||||
title: PrepareMaterialsResult
|
||||
type: object
|
||||
required:
|
||||
@@ -6090,7 +6015,6 @@ virtual_workbench:
|
||||
type: object
|
||||
type: UniLabJsonCommand
|
||||
auto-start_heating:
|
||||
always_free: true
|
||||
feedback: {}
|
||||
goal: {}
|
||||
goal_default:
|
||||
@@ -6138,25 +6062,6 @@ virtual_workbench:
|
||||
- material_number
|
||||
type: object
|
||||
result:
|
||||
$defs:
|
||||
LabSample:
|
||||
properties:
|
||||
extra:
|
||||
additionalProperties: true
|
||||
title: Extra
|
||||
type: object
|
||||
oss_path:
|
||||
title: Oss Path
|
||||
type: string
|
||||
sample_uuid:
|
||||
title: Sample Uuid
|
||||
type: string
|
||||
required:
|
||||
- sample_uuid
|
||||
- oss_path
|
||||
- extra
|
||||
title: LabSample
|
||||
type: object
|
||||
description: start_heating 返回类型
|
||||
properties:
|
||||
material_id:
|
||||
@@ -6174,18 +6079,12 @@ virtual_workbench:
|
||||
success:
|
||||
title: Success
|
||||
type: boolean
|
||||
unilabos_samples:
|
||||
items:
|
||||
$ref: '#/$defs/LabSample'
|
||||
title: Unilabos Samples
|
||||
type: array
|
||||
required:
|
||||
- success
|
||||
- station_id
|
||||
- material_id
|
||||
- material_number
|
||||
- message
|
||||
- unilabos_samples
|
||||
title: StartHeatingResult
|
||||
type: object
|
||||
required:
|
||||
|
||||
@@ -5,7 +5,6 @@ import sys
|
||||
import inspect
|
||||
import importlib
|
||||
import threading
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union, Tuple
|
||||
@@ -89,14 +88,6 @@ class Registry:
|
||||
)
|
||||
test_latency_schema["description"] = "用于测试延迟的动作,返回延迟时间和时间差。"
|
||||
|
||||
test_resource_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_resource", {})
|
||||
test_resource_schema = self._generate_unilab_json_command_schema(
|
||||
test_resource_method_info.get("args", []),
|
||||
"test_resource",
|
||||
test_resource_method_info.get("return_annotation"),
|
||||
)
|
||||
test_resource_schema["description"] = "用于测试物料、设备和样本。"
|
||||
|
||||
self.device_type_registry.update(
|
||||
{
|
||||
"host_node": {
|
||||
@@ -175,8 +166,7 @@ class Registry:
|
||||
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
|
||||
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
|
||||
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
|
||||
"class_name": "unilabos_class", # 当前实验室物料的class name
|
||||
"slot_on_deck": "unilabos_resource_slot:parent", # 勾选的parent的config中的sites的name,展示name,参数对应slot(index)
|
||||
"class_name": "unilabos_class",
|
||||
},
|
||||
},
|
||||
"test_latency": {
|
||||
@@ -199,7 +189,32 @@ class Registry:
|
||||
"goal": {},
|
||||
"feedback": {},
|
||||
"result": {},
|
||||
"schema": test_resource_schema,
|
||||
"schema": {
|
||||
"description": "",
|
||||
"properties": {
|
||||
"feedback": {},
|
||||
"goal": {
|
||||
"properties": {
|
||||
"resource": ros_message_to_json_schema(Resource, "resource"),
|
||||
"resources": {
|
||||
"items": {
|
||||
"properties": ros_message_to_json_schema(
|
||||
Resource, "resources"
|
||||
),
|
||||
"type": "object",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"device": {"type": "string"},
|
||||
"devices": {"items": {"type": "string"}, "type": "array"},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
"result": {},
|
||||
},
|
||||
"title": "test_resource",
|
||||
"type": "object",
|
||||
},
|
||||
"placeholder_keys": {
|
||||
"device": "unilabos_devices",
|
||||
"devices": "unilabos_devices",
|
||||
@@ -823,7 +838,6 @@ class Registry:
|
||||
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
|
||||
]
|
||||
},
|
||||
**({"always_free": True} if v.get("always_free") else {}),
|
||||
}
|
||||
for k, v in enhanced_info["action_methods"].items()
|
||||
if k not in device_config["class"]["action_value_mappings"]
|
||||
@@ -929,7 +943,6 @@ class Registry:
|
||||
if is_valid:
|
||||
results.append((file, data, device_ids))
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
logger.warning(f"[UniLab Registry] 处理设备文件异常: {file}, 错误: {e}")
|
||||
|
||||
# 线程安全地更新注册表
|
||||
|
||||
@@ -46,3 +46,16 @@ BIOYOND_PolymerStation_8StockCarrier:
|
||||
init_param_schema: {}
|
||||
registry_type: resource
|
||||
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
|
||||
|
||||
@@ -82,14 +82,3 @@ BIOYOND_PolymerStation_Solution_Beaker:
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.bioyond.bottles import (
|
||||
@@ -9,6 +9,28 @@ from unilabos.resources.bioyond.bottles import (
|
||||
BIOYOND_PolymerStation_Reagent_Bottle,
|
||||
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
|
||||
|
||||
|
||||
@@ -322,3 +344,88 @@ def BIOYOND_Electrolyte_1BottleCarrier(name: str) -> BottleCarrier:
|
||||
carrier.num_items_z = 1
|
||||
carrier[0] = BIOYOND_PolymerStation_Solution_Beaker(f"{name}_beaker_1")
|
||||
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
|
||||
|
||||
@@ -116,7 +116,9 @@ def BIOYOND_PolymerStation_TipBox(
|
||||
size_z: float = 100.0, # 枪头盒高度
|
||||
barcode: str = None,
|
||||
):
|
||||
"""创建4×6枪头盒 (24个枪头)
|
||||
"""创建4×6枪头盒 (24个枪头) - 使用 BottleCarrier 结构
|
||||
|
||||
注意:此函数已弃用,请使用 bottle_carriers.py 中的版本
|
||||
|
||||
Args:
|
||||
name: 枪头盒名称
|
||||
@@ -126,55 +128,11 @@ def BIOYOND_PolymerStation_TipBox(
|
||||
barcode: 条形码
|
||||
|
||||
Returns:
|
||||
TipBoxCarrier: 包含24个枪头孔位的枪头盒
|
||||
BottleCarrier: 包含24个枪头孔位的枪头盒载架
|
||||
"""
|
||||
from pylabrobot.resources import Container, Coordinate
|
||||
|
||||
# 创建枪头盒容器
|
||||
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
|
||||
# 重定向到 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)
|
||||
|
||||
|
||||
def BIOYOND_PolymerStation_Flask(
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
|
||||
from pylabrobot.resources import Container
|
||||
from unilabos_msgs.msg import Resource
|
||||
|
||||
from unilabos.ros.msgs.message_converter import convert_from_ros_msg
|
||||
|
||||
|
||||
class RegularContainer(Container):
|
||||
@@ -12,12 +16,12 @@ class RegularContainer(Container):
|
||||
kwargs["size_y"] = 0
|
||||
if "size_z" not in kwargs:
|
||||
kwargs["size_z"] = 0
|
||||
|
||||
self.kwargs = kwargs
|
||||
self.state = {}
|
||||
super().__init__(*args, category="container", **kwargs)
|
||||
|
||||
def load_state(self, state: Dict[str, Any]):
|
||||
super().load_state(state)
|
||||
self.state = state
|
||||
|
||||
|
||||
def get_regular_container(name="container"):
|
||||
@@ -25,6 +29,7 @@ def get_regular_container(name="container"):
|
||||
r.category = "container"
|
||||
return r
|
||||
|
||||
#
|
||||
# class RegularContainer(object):
|
||||
# # 第一个参数必须是id传入
|
||||
# # noinspection PyShadowingBuiltins
|
||||
@@ -84,4 +89,4 @@ def get_regular_container(name="container"):
|
||||
# return to_dict
|
||||
#
|
||||
# def __str__(self):
|
||||
# return f"{self.id}"
|
||||
# return f"{self.id}"
|
||||
@@ -76,7 +76,7 @@ def canonicalize_nodes_data(
|
||||
if sample_id:
|
||||
logger.error(f"{node}的sample_id参数已弃用,sample_id: {sample_id}")
|
||||
for k in list(node.keys()):
|
||||
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose", "extra"]:
|
||||
if k not in ["id", "uuid", "name", "description", "schema", "model", "icon", "parent_uuid", "parent", "type", "class", "position", "config", "data", "children", "pose"]:
|
||||
v = node.pop(k)
|
||||
node["config"][k] = v
|
||||
if outer_host_node_id is not None:
|
||||
@@ -779,9 +779,12 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
bottle = plr_material[number] = initialize_resource(
|
||||
{"name": f'{detail["name"]}_{number}', "class": reverse_type_mapping[typeName][0]}, resource_type=ResourcePLR
|
||||
)
|
||||
bottle.tracker.liquids = [
|
||||
(detail["name"], float(detail.get("quantity", 0)) if detail.get("quantity") else 0)
|
||||
]
|
||||
# 只有具有 tracker 属性的容器才设置液体信息(如 Bottle, Well)
|
||||
# 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", "")
|
||||
logger.debug(f" └─ [子物料] {detail['name']} → {plr_material.name}[{number}] (类型:{typeName})")
|
||||
else:
|
||||
@@ -790,9 +793,11 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
# 只对有 capacity 属性的容器(液体容器)处理液体追踪
|
||||
if hasattr(plr_material, 'capacity'):
|
||||
bottle = plr_material[0] if plr_material.capacity > 0 else plr_material
|
||||
bottle.tracker.liquids = [
|
||||
(material["name"], float(material.get("quantity", 0)) if material.get("quantity") else 0)
|
||||
]
|
||||
# 确保 bottle 有 tracker 属性才设置液体信息
|
||||
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)
|
||||
|
||||
@@ -821,24 +826,29 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
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')})")
|
||||
|
||||
# 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右"
|
||||
# 根据列号(x)判断: 1-4映射到左侧, 5-8映射到右侧
|
||||
# 根据列号(y)判断: 1-4映射到左侧, 5-8映射到右侧
|
||||
if wh_name == "堆栈1":
|
||||
x_val = loc.get("x", 1)
|
||||
if 1 <= x_val <= 4:
|
||||
if 1 <= y <= 4:
|
||||
wh_name = "堆栈1左"
|
||||
elif 5 <= x_val <= 8:
|
||||
elif 5 <= y <= 8:
|
||||
wh_name = "堆栈1右"
|
||||
y = y - 4 # 调整列号: 5-8映射到1-4
|
||||
else:
|
||||
logger.warning(f"物料 {material['name']} 的列号 x={x_val} 超出范围,无法映射到堆栈1左或堆栈1右")
|
||||
logger.warning(f"物料 {material['name']} 的列号 y={y} 超出范围,无法映射到堆栈1左或堆栈1右")
|
||||
continue
|
||||
|
||||
# 特殊处理: Bioyond的"站内Tip盒堆栈"也需要进行拆分映射
|
||||
if wh_name == "站内Tip盒堆栈":
|
||||
y_val = loc.get("y", 1)
|
||||
if y_val == 1:
|
||||
if y == 1:
|
||||
wh_name = "站内Tip盒堆栈(右)"
|
||||
elif y_val in [2, 3]:
|
||||
elif y in [2, 3]:
|
||||
wh_name = "站内Tip盒堆栈(左)"
|
||||
y = y - 1 # 调整列号,因为左侧仓库对应的 Bioyond y=2 实际上是它的第1列
|
||||
|
||||
@@ -846,15 +856,6 @@ def resource_bioyond_to_plr(bioyond_materials: list[dict], type_mapping: Dict[st
|
||||
warehouse = deck.warehouses[wh_name]
|
||||
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使用 vertical-col-major 布局
|
||||
if wh_name in ["站内试剂存放堆栈", "测量小瓶仓库(测密度)"]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -18,3 +18,9 @@ def register():
|
||||
from unilabos.devices.liquid_handling.rviz_backend import UniLiquidHandlerRvizBackend
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -5,8 +5,6 @@ from pydantic import BaseModel, field_serializer, field_validator, ValidationErr
|
||||
from pydantic import Field
|
||||
from typing import List, Tuple, Any, Dict, Literal, Optional, cast, TYPE_CHECKING, Union
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from unilabos.resources.plr_additional_res_reg import register
|
||||
from unilabos.utils.log import logger
|
||||
|
||||
@@ -16,33 +14,6 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
EXTRA_CLASS = "unilabos_resource_class"
|
||||
FRONTEND_POSE_EXTRA = "unilabos_frontend_pose_extra"
|
||||
EXTRA_SAMPLE_UUID = "sample_uuid"
|
||||
EXTRA_UNILABOS_SAMPLE_UUID = "unilabos_sample_uuid"
|
||||
|
||||
# 函数参数名常量 - 用于自动注入 sample_uuids 列表
|
||||
PARAM_SAMPLE_UUIDS = "sample_uuids"
|
||||
|
||||
# JSON Command 中的系统参数字段名
|
||||
JSON_UNILABOS_PARAM = "unilabos_param"
|
||||
|
||||
# 返回值中的 samples 字段名
|
||||
RETURN_UNILABOS_SAMPLES = "unilabos_samples"
|
||||
|
||||
# sample_uuids 参数类型 (用于 virtual bench 等设备添加 sample_uuids 参数)
|
||||
SampleUUIDsType = Dict[str, Optional["PLRResource"]]
|
||||
|
||||
|
||||
class LabSample(TypedDict):
|
||||
sample_uuid: str
|
||||
oss_path: str
|
||||
extra: Dict[str, Any]
|
||||
|
||||
|
||||
class ResourceDictPositionSizeType(TypedDict):
|
||||
depth: float
|
||||
width: float
|
||||
height: float
|
||||
|
||||
|
||||
class ResourceDictPositionSize(BaseModel):
|
||||
@@ -51,48 +22,18 @@ class ResourceDictPositionSize(BaseModel):
|
||||
height: float = Field(description="Height", default=0.0) # y
|
||||
|
||||
|
||||
class ResourceDictPositionScaleType(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class ResourceDictPositionScale(BaseModel):
|
||||
x: float = Field(description="x scale", default=0.0)
|
||||
y: float = Field(description="y scale", default=0.0)
|
||||
z: float = Field(description="z scale", default=0.0)
|
||||
|
||||
|
||||
class ResourceDictPositionObjectType(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class ResourceDictPositionObject(BaseModel):
|
||||
x: float = Field(description="X coordinate", default=0.0)
|
||||
y: float = Field(description="Y coordinate", default=0.0)
|
||||
z: float = Field(description="Z coordinate", default=0.0)
|
||||
|
||||
|
||||
class ResourceDictPoseExtraObjectType(BaseModel):
|
||||
z_index: int
|
||||
|
||||
|
||||
class ResourceDictPoseExtraObject(BaseModel):
|
||||
z_index: Optional[int] = Field(alias="zIndex", default=None)
|
||||
|
||||
|
||||
class ResourceDictPositionType(TypedDict):
|
||||
size: ResourceDictPositionSizeType
|
||||
scale: ResourceDictPositionScaleType
|
||||
layout: Literal["2d", "x-y", "z-y", "x-z"]
|
||||
position: ResourceDictPositionObjectType
|
||||
position3d: ResourceDictPositionObjectType
|
||||
rotation: ResourceDictPositionObjectType
|
||||
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"]
|
||||
|
||||
|
||||
class ResourceDictPosition(BaseModel):
|
||||
size: ResourceDictPositionSize = Field(description="Resource size", default_factory=ResourceDictPositionSize)
|
||||
scale: ResourceDictPositionScale = Field(description="Resource scale", default_factory=ResourceDictPositionScale)
|
||||
@@ -109,25 +50,6 @@ class ResourceDictPosition(BaseModel):
|
||||
cross_section_type: Literal["rectangle", "circle", "rounded_rectangle"] = Field(
|
||||
description="Cross section type", default="rectangle"
|
||||
)
|
||||
extra: Optional[ResourceDictPoseExtraObject] = Field(description="Extra data", default=None)
|
||||
|
||||
|
||||
class ResourceDictType(TypedDict):
|
||||
id: str
|
||||
uuid: str
|
||||
name: str
|
||||
description: str
|
||||
resource_schema: Dict[str, Any]
|
||||
model: Dict[str, Any]
|
||||
icon: str
|
||||
parent_uuid: Optional[str]
|
||||
parent: Optional["ResourceDictType"]
|
||||
type: Union[Literal["device"], str]
|
||||
klass: str
|
||||
pose: ResourceDictPositionType
|
||||
config: Dict[str, Any]
|
||||
data: Dict[str, Any]
|
||||
extra: Dict[str, Any]
|
||||
|
||||
|
||||
# 统一的资源字典模型,parent 自动序列化为 parent_uuid,children 不序列化
|
||||
@@ -419,17 +341,9 @@ class ResourceTreeSet(object):
|
||||
"deck": "deck",
|
||||
"tip_rack": "tip_rack",
|
||||
"tip_spot": "tip_spot",
|
||||
"tip": "tip", # 添加 tip 类型支持
|
||||
"tube": "tube",
|
||||
"bottle_carrier": "bottle_carrier",
|
||||
"material_hole": "material_hole",
|
||||
"container": "container",
|
||||
"material_plate": "material_plate",
|
||||
"electrode_sheet": "electrode_sheet",
|
||||
"warehouse": "warehouse",
|
||||
"magazine_holder": "magazine_holder",
|
||||
"resource_group": "resource_group",
|
||||
"trash": "trash",
|
||||
"plate_adapter": "plate_adapter",
|
||||
}
|
||||
if source in replace_info:
|
||||
return replace_info[source]
|
||||
@@ -473,7 +387,6 @@ class ResourceTreeSet(object):
|
||||
"position3d": raw_pos,
|
||||
"rotation": d["rotation"],
|
||||
"cross_section_type": d.get("cross_section_type", "rectangle"),
|
||||
"extra": extra.get(FRONTEND_POSE_EXTRA)
|
||||
}
|
||||
|
||||
# 先构建当前节点的字典(不包含children)
|
||||
@@ -534,17 +447,10 @@ class ResourceTreeSet(object):
|
||||
trees.append(tree_instance)
|
||||
return cls(trees)
|
||||
|
||||
def to_plr_resources(
|
||||
self, skip_devices: bool = True, requested_uuids: Optional[List[str]] = None
|
||||
) -> List["PLRResource"]:
|
||||
def to_plr_resources(self, skip_devices=True) -> List["PLRResource"]:
|
||||
"""
|
||||
将 ResourceTreeSet 转换为 PLR 资源列表
|
||||
|
||||
Args:
|
||||
skip_devices: 是否跳过 device 类型节点
|
||||
requested_uuids: 若指定,则按此 UUID 顺序返回对应资源(用于批量查询时一一对应),
|
||||
否则返回各树的根节点列表
|
||||
|
||||
Returns:
|
||||
List[PLRResource]: PLR 资源实例列表
|
||||
"""
|
||||
@@ -566,7 +472,6 @@ class ResourceTreeSet(object):
|
||||
name_to_uuid[node.res_content.name] = node.res_content.uuid
|
||||
all_states[node.res_content.name] = node.res_content.data
|
||||
name_to_extra[node.res_content.name] = node.res_content.extra
|
||||
name_to_extra[node.res_content.name][FRONTEND_POSE_EXTRA] = node.res_content.pose.extra
|
||||
name_to_extra[node.res_content.name][EXTRA_CLASS] = node.res_content.klass
|
||||
for child in node.children:
|
||||
collect_node_data(child, name_to_uuid, all_states, name_to_extra)
|
||||
@@ -600,71 +505,6 @@ class ResourceTreeSet(object):
|
||||
d["model"] = res.config.get("model", None)
|
||||
return d
|
||||
|
||||
# deserialize 会单独处理的元数据 key,不传给构造函数
|
||||
_META_KEYS = {"type", "parent_name", "location", "children", "rotation", "barcode"}
|
||||
# deserialize 自定义逻辑使用的 key(如 TipSpot 用 prototype_tip 构建 make_tip),需保留
|
||||
_DESERIALIZE_PRESERVED_KEYS = {"prototype_tip"}
|
||||
|
||||
def remove_incompatible_params(plr_d: dict) -> None:
|
||||
"""递归移除 PLR 类不接受的参数,避免 deserialize 报错。
|
||||
- 移除构造函数不接受的参数(如 compute_height_from_volume、ordering、category)
|
||||
- 对 TubeRack:将 ordering 转为 ordered_items
|
||||
- 保留 deserialize 自定义逻辑需要的 key(如 prototype_tip)
|
||||
"""
|
||||
if "type" in plr_d:
|
||||
sub_cls = find_subclass(plr_d["type"], PLRResource)
|
||||
if sub_cls is not None:
|
||||
spec = inspect.signature(sub_cls)
|
||||
valid_params = set(spec.parameters.keys())
|
||||
# TubeRack 特殊处理:先转换 ordering,再参与后续过滤
|
||||
if "ordering" not in valid_params and "ordering" in plr_d:
|
||||
ordering = plr_d.pop("ordering", None)
|
||||
if sub_cls.__name__ == "TubeRack":
|
||||
plr_d["ordered_items"] = (
|
||||
_ordering_to_ordered_items(plr_d, ordering)
|
||||
if ordering
|
||||
else {}
|
||||
)
|
||||
# 移除构造函数不接受的参数(保留 META 和 deserialize 自定义逻辑需要的 key)
|
||||
for key in list(plr_d.keys()):
|
||||
if (
|
||||
key not in _META_KEYS
|
||||
and key not in _DESERIALIZE_PRESERVED_KEYS
|
||||
and key not in valid_params
|
||||
):
|
||||
plr_d.pop(key, None)
|
||||
for child in plr_d.get("children", []):
|
||||
remove_incompatible_params(child)
|
||||
|
||||
def _ordering_to_ordered_items(plr_d: dict, ordering: dict) -> dict:
|
||||
"""将 ordering 转为 ordered_items,从 children 构建 Tube 对象"""
|
||||
from pylabrobot.resources import Tube, Coordinate
|
||||
from pylabrobot.serializer import deserialize as plr_deserialize
|
||||
|
||||
children = plr_d.get("children", [])
|
||||
ordered_items = {}
|
||||
for idx, (ident, child_name) in enumerate(ordering.items()):
|
||||
child_data = children[idx] if idx < len(children) else None
|
||||
if child_data is None:
|
||||
continue
|
||||
loc_data = child_data.get("location")
|
||||
loc = (
|
||||
plr_deserialize(loc_data)
|
||||
if loc_data
|
||||
else Coordinate(0, 0, 0)
|
||||
)
|
||||
tube = Tube(
|
||||
name=child_data.get("name", child_name or ident),
|
||||
size_x=child_data.get("size_x", 10),
|
||||
size_y=child_data.get("size_y", 10),
|
||||
size_z=child_data.get("size_z", 50),
|
||||
max_volume=child_data.get("max_volume", 1000),
|
||||
)
|
||||
tube.location = loc
|
||||
ordered_items[ident] = tube
|
||||
plr_d["children"] = [] # 已并入 ordered_items,避免重复反序列化
|
||||
return ordered_items
|
||||
|
||||
plr_resources = []
|
||||
tracker = DeviceNodeResourceTracker()
|
||||
|
||||
@@ -684,11 +524,12 @@ class ResourceTreeSet(object):
|
||||
raise ValueError(
|
||||
f"无法找到类型 {plr_dict['type']} 对应的 PLR 资源类。原始信息:{tree.root_node.res_content}"
|
||||
)
|
||||
remove_incompatible_params(plr_dict)
|
||||
spec = inspect.signature(sub_cls)
|
||||
if "category" not in spec.parameters:
|
||||
plr_dict.pop("category", None)
|
||||
plr_resource = sub_cls.deserialize(plr_dict, allow_marshal=True)
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.serializer import deserialize
|
||||
|
||||
location = cast(Coordinate, deserialize(plr_dict["location"]))
|
||||
plr_resource.location = location
|
||||
plr_resource.load_all_state(all_states)
|
||||
@@ -704,41 +545,6 @@ class ResourceTreeSet(object):
|
||||
logger.error(f"堆栈: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
if requested_uuids:
|
||||
# 按请求的 UUID 顺序返回对应资源(从整棵树中按 uuid 提取)
|
||||
# 优先使用 tracker.uuid_to_resources;若映射缺失,再递归遍历 PLR 树兜底搜索。
|
||||
def _find_plr_by_uuid(roots: List["PLRResource"], uid: str) -> Optional["PLRResource"]:
|
||||
stack = list(roots)
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
node_uid = getattr(node, "unilabos_uuid", None)
|
||||
if node_uid == uid:
|
||||
return node
|
||||
children = getattr(node, "children", None) or []
|
||||
stack.extend(children)
|
||||
return None
|
||||
|
||||
result = []
|
||||
missing_uuids = []
|
||||
for uid in requested_uuids:
|
||||
found = tracker.uuid_to_resources.get(uid)
|
||||
if found is None:
|
||||
found = _find_plr_by_uuid(plr_resources, uid)
|
||||
if found is not None:
|
||||
# 回填缓存,后续相同 uuid 可直接命中
|
||||
tracker.uuid_to_resources[uid] = found
|
||||
if found is None:
|
||||
missing_uuids.append(uid)
|
||||
else:
|
||||
result.append(found)
|
||||
|
||||
if missing_uuids:
|
||||
raise ValueError(
|
||||
f"请求的 UUID 未在资源树中找到: {missing_uuids}。"
|
||||
f"可用 UUID 数量: {len(tracker.uuid_to_resources)},"
|
||||
f"资源树数量: {len(self.trees)}"
|
||||
)
|
||||
return result
|
||||
return plr_resources
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -44,7 +44,8 @@ def ros2_device_node(
|
||||
# 从属性中自动发现可发布状态
|
||||
if status_types is None:
|
||||
status_types = {}
|
||||
assert device_config is not None, "device_config cannot be None"
|
||||
if device_config is None:
|
||||
raise ValueError("device_config cannot be None")
|
||||
if action_value_mappings is None:
|
||||
action_value_mappings = {}
|
||||
if hardware_interface is None:
|
||||
|
||||
@@ -51,7 +51,6 @@ def main(
|
||||
bridges: List[Any] = [],
|
||||
visual: str = "disable",
|
||||
resources_mesh_config: dict = {},
|
||||
resources_mesh_resource_list: list = [],
|
||||
rclpy_init_args: List[str] = ["--log-level", "debug"],
|
||||
discovery_interval: float = 15.0,
|
||||
) -> None:
|
||||
@@ -78,12 +77,12 @@ def main(
|
||||
if visual != "disable":
|
||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
||||
|
||||
# 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
|
||||
if resources_mesh_resource_list:
|
||||
resources_list = resources_mesh_resource_list
|
||||
else:
|
||||
# fallback: 从 ResourceTreeSet 获取
|
||||
resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
|
||||
resources_list = (
|
||||
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
if resources_config
|
||||
else []
|
||||
)
|
||||
resource_mesh_manager = ResourceMeshManager(
|
||||
resources_mesh_config,
|
||||
resources_list,
|
||||
@@ -91,7 +90,7 @@ def main(
|
||||
device_id="resource_mesh_manager",
|
||||
device_uuid=str(uuid.uuid4()),
|
||||
)
|
||||
joint_republisher = JointRepublisher("joint_republisher","joint_republisher", host_node.resource_tracker)
|
||||
joint_republisher = JointRepublisher("joint_republisher", host_node.resource_tracker)
|
||||
# lh_joint_pub = LiquidHandlerJointPublisher(
|
||||
# resources_config=resources_list, resource_tracker=host_node.resource_tracker
|
||||
# )
|
||||
@@ -115,7 +114,6 @@ def slave(
|
||||
bridges: List[Any] = [],
|
||||
visual: str = "disable",
|
||||
resources_mesh_config: dict = {},
|
||||
resources_mesh_resource_list: list = [],
|
||||
rclpy_init_args: List[str] = ["--log-level", "debug"],
|
||||
) -> None:
|
||||
"""从节点函数"""
|
||||
@@ -210,12 +208,12 @@ def slave(
|
||||
if visual != "disable":
|
||||
from unilabos.ros.nodes.presets.joint_republisher import JointRepublisher
|
||||
|
||||
# 优先使用从 main.py 传入的完整资源列表(包含所有子资源)
|
||||
if resources_mesh_resource_list:
|
||||
resources_list = resources_mesh_resource_list
|
||||
else:
|
||||
# fallback: 从 ResourceTreeSet 获取
|
||||
resources_list = [node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
# 将 ResourceTreeSet 转换为 list 用于 visual 组件
|
||||
resources_list = (
|
||||
[node.res_content.model_dump(by_alias=True) for node in resources_config.all_nodes]
|
||||
if resources_config
|
||||
else []
|
||||
)
|
||||
resource_mesh_manager = ResourceMeshManager(
|
||||
resources_mesh_config,
|
||||
resources_list,
|
||||
|
||||
@@ -4,20 +4,8 @@ import json
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import (
|
||||
get_type_hints,
|
||||
TypeVar,
|
||||
Generic,
|
||||
Dict,
|
||||
Any,
|
||||
Type,
|
||||
TypedDict,
|
||||
Optional,
|
||||
List,
|
||||
TYPE_CHECKING,
|
||||
Union,
|
||||
Tuple,
|
||||
)
|
||||
from typing import get_type_hints, TypeVar, Generic, Dict, Any, Type, TypedDict, Optional, List, TYPE_CHECKING, Union, \
|
||||
Tuple
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import asyncio
|
||||
@@ -60,9 +48,6 @@ from unilabos.resources.resource_tracker import (
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
ResourceDictInstance,
|
||||
EXTRA_SAMPLE_UUID,
|
||||
PARAM_SAMPLE_UUIDS,
|
||||
JSON_UNILABOS_PARAM,
|
||||
)
|
||||
from unilabos.ros.utils.driver_creator import WorkstationNodeCreator, PyLabRobotCreator, DeviceClassCreator
|
||||
from rclpy.task import Task, Future
|
||||
@@ -146,7 +131,7 @@ def init_wrapper(
|
||||
device_id: str,
|
||||
device_uuid: str,
|
||||
driver_class: type[T],
|
||||
device_config: ResourceDictInstance,
|
||||
device_config: ResourceTreeInstance,
|
||||
status_types: Dict[str, Any],
|
||||
action_value_mappings: Dict[str, Any],
|
||||
hardware_interface: Dict[str, Any],
|
||||
@@ -231,15 +216,14 @@ class PropertyPublisher:
|
||||
|
||||
def publish_property(self):
|
||||
try:
|
||||
# self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
||||
self.node.lab_logger().trace(f"【.publish_property】开始发布属性: {self.name}")
|
||||
value = self.get_property()
|
||||
if self.print_publish:
|
||||
pass
|
||||
# self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
||||
self.node.lab_logger().trace(f"【.publish_property】发布 {self.msg_type}: {value}")
|
||||
if value is not None:
|
||||
msg = convert_to_ros_msg(self.msg_type, value)
|
||||
self.publisher_.publish(msg)
|
||||
# self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
||||
self.node.lab_logger().trace(f"【.publish_property】属性 {self.name} 发布成功")
|
||||
except Exception as e:
|
||||
self.node.lab_logger().error(
|
||||
f"【.publish_property】发布属性 {self.publisher_.topic} 出错: {str(e)}\n{traceback.format_exc()}"
|
||||
@@ -279,7 +263,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
self,
|
||||
driver_instance: T,
|
||||
device_id: str,
|
||||
registry_name: str,
|
||||
device_uuid: str,
|
||||
status_types: Dict[str, Any],
|
||||
action_value_mappings: Dict[str, Any],
|
||||
@@ -301,7 +284,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
"""
|
||||
self.driver_instance = driver_instance
|
||||
self.device_id = device_id
|
||||
self.registry_name = registry_name
|
||||
self.uuid = device_uuid
|
||||
self.publish_high_frequency = False
|
||||
self.callback_group = ReentrantCallbackGroup()
|
||||
@@ -379,7 +361,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
from pylabrobot.resources.deck import Deck
|
||||
from pylabrobot.resources import Coordinate
|
||||
from pylabrobot.resources import Plate
|
||||
|
||||
# 物料传输到对应的node节点
|
||||
client = self._resource_clients["c2s_update_resource_tree"]
|
||||
request = SerialCommand.Request()
|
||||
@@ -407,29 +388,33 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
rts: ResourceTreeSet = ResourceTreeSet.from_raw_dict_list(input_resources)
|
||||
parent_resource = None
|
||||
if bind_parent_id != self.node_name:
|
||||
parent_resource = self.resource_tracker.figure_resource({"name": bind_parent_id})
|
||||
parent_resource = self.resource_tracker.figure_resource(
|
||||
{"name": bind_parent_id}
|
||||
)
|
||||
for r in rts.root_nodes:
|
||||
# noinspection PyUnresolvedReferences
|
||||
r.res_content.parent_uuid = parent_resource.unilabos_uuid
|
||||
else:
|
||||
for r in rts.root_nodes:
|
||||
r.res_content.parent_uuid = self.uuid
|
||||
rts_plr_instances = rts.to_plr_resources()
|
||||
if len(rts.root_nodes) == 1 and isinstance(rts_plr_instances[0], RegularContainer):
|
||||
|
||||
if len(LIQUID_INPUT_SLOT) and LIQUID_INPUT_SLOT[0] == -1 and len(rts.root_nodes) == 1 and isinstance(rts.root_nodes[0], RegularContainer):
|
||||
# noinspection PyTypeChecker
|
||||
container_instance: RegularContainer = rts_plr_instances[0]
|
||||
container_instance: RegularContainer = rts.root_nodes[0]
|
||||
found_resources = self.resource_tracker.figure_resource(
|
||||
{"name": container_instance.name}, try_mode=True
|
||||
{"id": container_instance.name}, try_mode=True
|
||||
)
|
||||
if not len(found_resources):
|
||||
self.resource_tracker.add_resource(container_instance)
|
||||
logger.info(f"添加物料{container_instance.name}到资源跟踪器")
|
||||
else:
|
||||
assert len(found_resources) == 1, f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
||||
assert (
|
||||
len(found_resources) == 1
|
||||
), f"找到多个同名物料: {container_instance.name}, 请检查物料系统"
|
||||
found_resource = found_resources[0]
|
||||
if isinstance(found_resource, RegularContainer):
|
||||
logger.info(f"更新物料{container_instance.name}的数据{found_resource.state}")
|
||||
found_resource.state.update(container_instance.state)
|
||||
found_resource.state.update(json.loads(container_instance.state))
|
||||
elif isinstance(found_resource, dict):
|
||||
raise ValueError("已不支持 字典 版本的RegularContainer")
|
||||
else:
|
||||
@@ -437,16 +422,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
f"更新物料{container_instance.name}出现不支持的数据类型{type(found_resource)} {found_resource}"
|
||||
)
|
||||
# noinspection PyUnresolvedReferences
|
||||
request.command = json.dumps(
|
||||
{
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts.dump(),
|
||||
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else self.uuid,
|
||||
"first_add": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
request.command = json.dumps({
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts.dump(),
|
||||
"mount_uuid": parent_resource.unilabos_uuid if parent_resource is not None else "",
|
||||
"first_add": False,
|
||||
},
|
||||
})
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
plr_instances = rts.to_plr_resources()
|
||||
@@ -460,7 +443,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
}
|
||||
res.response = json.dumps(final_response)
|
||||
# 如果driver自己就有assign的方法,那就使用driver自己的assign方法
|
||||
if hasattr(self.driver_instance, "create_resource") and self.node_name != "host_node":
|
||||
if hasattr(self.driver_instance, "create_resource"):
|
||||
create_resource_func = getattr(self.driver_instance, "create_resource")
|
||||
try:
|
||||
ret = create_resource_func(
|
||||
@@ -488,9 +471,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if len(ADD_LIQUID_TYPE) == 1 and len(LIQUID_VOLUME) == 1 and len(LIQUID_INPUT_SLOT) > 1:
|
||||
ADD_LIQUID_TYPE = ADD_LIQUID_TYPE * len(LIQUID_INPUT_SLOT)
|
||||
LIQUID_VOLUME = LIQUID_VOLUME * len(LIQUID_INPUT_SLOT)
|
||||
self.lab_logger().warning(
|
||||
f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个"
|
||||
)
|
||||
self.lab_logger().warning(f"增加液体资源时,数量为1,自动补全为 {len(LIQUID_INPUT_SLOT)} 个")
|
||||
for liquid_type, liquid_volume, liquid_input_slot in zip(
|
||||
ADD_LIQUID_TYPE, LIQUID_VOLUME, LIQUID_INPUT_SLOT
|
||||
):
|
||||
@@ -509,15 +490,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
input_wells = []
|
||||
for r in LIQUID_INPUT_SLOT:
|
||||
input_wells.append(plr_instance.children[r])
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(
|
||||
input_wells
|
||||
).dump()
|
||||
final_response["liquid_input_resource_tree"] = ResourceTreeSet.from_plr_resources(input_wells).dump()
|
||||
res.response = json.dumps(final_response)
|
||||
if (
|
||||
issubclass(parent_resource.__class__, Deck)
|
||||
and hasattr(parent_resource, "assign_child_at_slot")
|
||||
and "slot" in other_calling_param
|
||||
):
|
||||
if issubclass(parent_resource.__class__, Deck) and hasattr(parent_resource, "assign_child_at_slot") and "slot" in other_calling_param:
|
||||
other_calling_param["slot"] = int(other_calling_param["slot"])
|
||||
parent_resource.assign_child_at_slot(plr_instance, **other_calling_param)
|
||||
else:
|
||||
@@ -532,16 +507,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
rts_with_parent = ResourceTreeSet.from_plr_resources([parent_resource])
|
||||
if rts_with_parent.root_nodes[0].res_content.uuid_parent is None:
|
||||
rts_with_parent.root_nodes[0].res_content.parent_uuid = self.uuid
|
||||
request.command = json.dumps(
|
||||
{
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts_with_parent.dump(),
|
||||
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
|
||||
"first_add": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
request.command = json.dumps({
|
||||
"action": "add",
|
||||
"data": {
|
||||
"data": rts_with_parent.dump(),
|
||||
"mount_uuid": rts_with_parent.root_nodes[0].res_content.uuid_parent,
|
||||
"first_add": False,
|
||||
},
|
||||
})
|
||||
tree_response: SerialCommand.Response = await client.call_async(request)
|
||||
uuid_maps = json.loads(tree_response.response)
|
||||
self.resource_tracker.loop_update_uuid(input_resources, uuid_maps)
|
||||
@@ -838,9 +811,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
}
|
||||
|
||||
def _handle_update(
|
||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]],
|
||||
tree_set: ResourceTreeSet,
|
||||
additional_add_params: Dict[str, Any],
|
||||
plr_resources: List[Union[ResourcePLR, ResourceDictInstance]], tree_set: ResourceTreeSet, additional_add_params: Dict[str, Any]
|
||||
) -> Tuple[Dict[str, Any], List[ResourcePLR]]:
|
||||
"""
|
||||
处理资源更新操作的内部函数
|
||||
@@ -865,10 +836,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
original_parent_resource = original_instance.parent
|
||||
original_parent_resource_uuid = getattr(original_parent_resource, "unilabos_uuid", None)
|
||||
target_parent_resource_uuid = tree.root_node.res_content.uuid_parent
|
||||
not_same_parent = (
|
||||
original_parent_resource_uuid != target_parent_resource_uuid
|
||||
and original_parent_resource is not None
|
||||
)
|
||||
not_same_parent = original_parent_resource_uuid != target_parent_resource_uuid and original_parent_resource is not None
|
||||
old_name = original_instance.name
|
||||
new_name = plr_resource.name
|
||||
parent_appended = False
|
||||
@@ -904,35 +872,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
else:
|
||||
# 判断是否变更了resource_site,重新登记
|
||||
target_site = original_instance.unilabos_extra.get("update_resource_site")
|
||||
sites = (
|
||||
original_instance.parent.sites
|
||||
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
|
||||
else None
|
||||
)
|
||||
site_names = (
|
||||
list(original_instance.parent._ordering.keys())
|
||||
if original_instance.parent is not None and hasattr(original_instance.parent, "sites")
|
||||
else []
|
||||
)
|
||||
sites = original_instance.parent.sites if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else None
|
||||
site_names = list(original_instance.parent._ordering.keys()) if original_instance.parent is not None and hasattr(original_instance.parent, "sites") else []
|
||||
if target_site is not None and sites is not None and site_names is not None:
|
||||
site_index = None
|
||||
try:
|
||||
# sites 可能是 Resource 列表或 dict 列表 (如 PRCXI9300Deck)
|
||||
# 只有itemized_carrier在使用,准备弃用
|
||||
site_index = sites.index(original_instance)
|
||||
except ValueError:
|
||||
# dict 类型的 sites: 通过name匹配
|
||||
for idx, site in enumerate(sites):
|
||||
if original_instance.name == site["occupied_by"]:
|
||||
site_index = idx
|
||||
break
|
||||
elif (original_instance.location.x == site["position"]["x"] and original_instance.location.y == site["position"]["y"] and original_instance.location.z == site["position"]["z"]):
|
||||
site_index = idx
|
||||
break
|
||||
if site_index is None:
|
||||
site_name = None
|
||||
else:
|
||||
site_name = site_names[site_index]
|
||||
site_index = sites.index(original_instance)
|
||||
site_name = site_names[site_index]
|
||||
if site_name != target_site:
|
||||
parent = self.transfer_to_new_resource(original_instance, tree, additional_add_params)
|
||||
if parent is not None:
|
||||
@@ -940,14 +884,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
parent_appended = True
|
||||
|
||||
# 加载状态
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_x = plr_resource._size_x
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_y = plr_resource._size_y
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._size_z = plr_resource._size_z
|
||||
# noinspection PyProtectedMember
|
||||
original_instance._local_size_z = plr_resource._local_size_z
|
||||
original_instance.location = plr_resource.location
|
||||
original_instance.rotation = plr_resource.rotation
|
||||
original_instance.barcode = plr_resource.barcode
|
||||
@@ -974,7 +910,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
action = i.get("action") # remove, add, update
|
||||
resources_uuid: List[str] = i.get("data") # 资源数据
|
||||
additional_add_params = i.get("additional_add_params", {}) # 额外参数
|
||||
self.lab_logger().trace(f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}")
|
||||
self.lab_logger().trace(
|
||||
f"[资源同步] 处理 {action}, " f"resources count: {len(resources_uuid)}"
|
||||
)
|
||||
tree_set = None
|
||||
if action in ["add", "update"]:
|
||||
tree_set = await self.get_resource(
|
||||
@@ -1001,14 +939,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}
|
||||
) # 和Update Resource一致
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
self.lab_logger().trace(f"确认资源云端 Add 结果: {response.response}")
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Add 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "update":
|
||||
if tree_set is None:
|
||||
@@ -1027,14 +961,10 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
tree.root_node.res_content.parent_uuid = self.uuid
|
||||
r = SerialCommand.Request()
|
||||
r.command = json.dumps(
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}
|
||||
) # 和Update Resource一致
|
||||
{"data": {"data": new_tree_set.dump()}, "action": "update"}) # 和Update Resource一致
|
||||
response: SerialCommand_Response = await self._resource_clients[
|
||||
"c2s_update_resource_tree"
|
||||
].call_async(
|
||||
r
|
||||
) # type: ignore
|
||||
self.lab_logger().trace(f"确认资源云端 Update 结果: {response.response}")
|
||||
"c2s_update_resource_tree"].call_async(r) # type: ignore
|
||||
self.lab_logger().info(f"确认资源云端 Update 结果: {response.response}")
|
||||
results.append(result)
|
||||
elif action == "remove":
|
||||
result = _handle_remove(resources_uuid)
|
||||
@@ -1180,7 +1110,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
"machine_name": BasicConfig.machine_name,
|
||||
"type": "slave",
|
||||
"edge_device_id": self.device_id,
|
||||
"registry_name": self.registry_name,
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
@@ -1404,7 +1333,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
resource_id=resource_data["id"], with_children=True
|
||||
)
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||
queried_resources[idx] = plr_resource
|
||||
else:
|
||||
uuid_indices.append((idx, unilabos_uuid, resource_data))
|
||||
@@ -1417,7 +1346,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
for i, (idx, _, resource_data) in enumerate(uuid_indices):
|
||||
plr_resource = plr_resources[i]
|
||||
if "sample_id" in resource_data:
|
||||
plr_resource.unilabos_extra[EXTRA_SAMPLE_UUID] = resource_data["sample_id"]
|
||||
plr_resource.unilabos_extra["sample_uuid"] = resource_data["sample_id"]
|
||||
queried_resources[idx] = plr_resource
|
||||
|
||||
self.lab_logger().debug(f"资源查询结果: 共 {len(queried_resources)} 个资源")
|
||||
@@ -1425,9 +1354,7 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
# 通过资源跟踪器获取本地实例
|
||||
final_resources = queried_resources if is_sequence else queried_resources[0]
|
||||
if not is_sequence:
|
||||
plr = self.resource_tracker.figure_resource(
|
||||
{"name": final_resources.name}, try_mode=False
|
||||
)
|
||||
plr = self.resource_tracker.figure_resource({"name": final_resources.name}, try_mode=False)
|
||||
# 保留unilabos_extra
|
||||
if hasattr(final_resources, "unilabos_extra") and hasattr(plr, "unilabos_extra"):
|
||||
plr.unilabos_extra = getattr(final_resources, "unilabos_extra", {}).copy()
|
||||
@@ -1466,12 +1393,8 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
execution_success = True
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
|
||||
)
|
||||
trace(
|
||||
f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
error(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(f"异步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
|
||||
future = ROS2DeviceNode.run_async_func(ACTION, trace_error=False, **action_kwargs)
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
@@ -1491,11 +1414,9 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
except Exception as _:
|
||||
execution_error = traceback.format_exc()
|
||||
error(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}"
|
||||
)
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{str(action_kwargs)[:1000]}")
|
||||
trace(
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}"
|
||||
)
|
||||
f"同步任务 {ACTION.__name__} 报错了\n{traceback.format_exc()}\n原始输入:{action_kwargs}")
|
||||
|
||||
future.add_done_callback(_handle_future_exception)
|
||||
|
||||
@@ -1562,18 +1483,11 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
if isinstance(rs, list):
|
||||
for r in rs:
|
||||
res = self.resource_tracker.parent_resource(r) # 获取 resource 对象
|
||||
if res is None:
|
||||
res = rs
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
else:
|
||||
res = self.resource_tracker.parent_resource(rs)
|
||||
if res is None:
|
||||
res = rs
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
|
||||
# 使用新的资源树接口
|
||||
if unique_resources:
|
||||
@@ -1625,37 +1539,20 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
try:
|
||||
function_name = target["function_name"]
|
||||
function_args = target["function_args"]
|
||||
# 获取 unilabos 系统参数
|
||||
unilabos_param: Dict[str, Any] = target[JSON_UNILABOS_PARAM]
|
||||
|
||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||
function = getattr(self.driver_instance, function_name)
|
||||
assert callable(
|
||||
function
|
||||
), f"执行动作时JSON中的function_name对应的函数不可调用: {function_name}\n原JSON: {string}"
|
||||
|
||||
# 处理参数(包含 unilabos 系统参数如 sample_uuids)
|
||||
args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
|
||||
# 处理 ResourceSlot 类型参数
|
||||
args_list = default_manager._analyze_method_signature(function)["args"]
|
||||
for arg in args_list:
|
||||
arg_name = arg["name"]
|
||||
arg_type = arg["type"]
|
||||
|
||||
# 跳过不在 function_args 中的参数
|
||||
if arg_name not in function_args:
|
||||
# 处理 sample_uuids 参数注入
|
||||
if arg_name == PARAM_SAMPLE_UUIDS:
|
||||
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
|
||||
# 将 material uuid 转换为 resource 实例
|
||||
# key: sample_uuid, value: material_uuid -> resource 实例
|
||||
resolved_sample_uuids: Dict[str, Any] = {}
|
||||
for sample_uuid, material_uuid in raw_sample_uuids.items():
|
||||
if material_uuid and self.resource_tracker:
|
||||
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
|
||||
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
|
||||
else:
|
||||
resolved_sample_uuids[sample_uuid] = material_uuid
|
||||
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
|
||||
self.lab_logger().debug(f"[JsonCommand] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}")
|
||||
continue
|
||||
|
||||
# 处理单个 ResourceSlot
|
||||
@@ -1684,7 +1581,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
f"转换ResourceSlot列表参数 {arg_name} 失败: {e}\n{traceback.format_exc()}"
|
||||
)
|
||||
raise JsonCommandInitError(f"ResourceSlot列表参数转换失败: {arg_name}")
|
||||
|
||||
# todo: 默认反报送
|
||||
return function(**function_args)
|
||||
except KeyError as ex:
|
||||
@@ -1705,16 +1601,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
raise ValueError("至少需要提供一个 UUID")
|
||||
|
||||
uuids_list = list(uuids)
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(
|
||||
SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
"data": {"data": uuids_list, "with_children": True},
|
||||
"action": "get",
|
||||
}
|
||||
)
|
||||
future = self._resource_clients["c2s_update_resource_tree"].call_async(SerialCommand.Request(
|
||||
command=json.dumps(
|
||||
{
|
||||
"data": {"data": uuids_list, "with_children": True},
|
||||
"action": "get",
|
||||
}
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
# 等待结果(使用while循环,每次sleep 0.05秒,最多等待30秒)
|
||||
timeout = 30.0
|
||||
@@ -1772,9 +1666,6 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
try:
|
||||
function_name = target["function_name"]
|
||||
function_args = target["function_args"]
|
||||
# 获取 unilabos 系统参数
|
||||
unilabos_param: Dict[str, Any] = target.get(JSON_UNILABOS_PARAM, {})
|
||||
|
||||
assert isinstance(function_args, dict), "执行动作时JSON必须为dict类型\n原JSON: {string}"
|
||||
function = getattr(self.driver_instance, function_name)
|
||||
assert callable(
|
||||
@@ -1784,30 +1675,14 @@ class BaseROS2DeviceNode(Node, Generic[T]):
|
||||
function
|
||||
), f"执行动作时JSON中的function并非异步: {function_name}\n原JSON: {string}"
|
||||
|
||||
# 处理参数(包含 unilabos 系统参数如 sample_uuids)
|
||||
args_list = default_manager._analyze_method_signature(function, skip_unilabos_params=False)["args"]
|
||||
# 处理 ResourceSlot 类型参数
|
||||
args_list = default_manager._analyze_method_signature(function)["args"]
|
||||
for arg in args_list:
|
||||
arg_name = arg["name"]
|
||||
arg_type = arg["type"]
|
||||
|
||||
# 跳过不在 function_args 中的参数
|
||||
if arg_name not in function_args:
|
||||
# 处理 sample_uuids 参数注入
|
||||
if arg_name == PARAM_SAMPLE_UUIDS:
|
||||
raw_sample_uuids = unilabos_param.get(PARAM_SAMPLE_UUIDS, {})
|
||||
# 将 material uuid 转换为 resource 实例
|
||||
# key: sample_uuid, value: material_uuid -> resource 实例
|
||||
resolved_sample_uuids: Dict[str, Any] = {}
|
||||
for sample_uuid, material_uuid in raw_sample_uuids.items():
|
||||
if material_uuid and self.resource_tracker:
|
||||
resource = self.resource_tracker.uuid_to_resources.get(material_uuid)
|
||||
resolved_sample_uuids[sample_uuid] = resource if resource else material_uuid
|
||||
else:
|
||||
resolved_sample_uuids[sample_uuid] = material_uuid
|
||||
function_args[PARAM_SAMPLE_UUIDS] = resolved_sample_uuids
|
||||
self.lab_logger().debug(
|
||||
f"[JsonCommandAsync] 注入 {PARAM_SAMPLE_UUIDS}: {resolved_sample_uuids}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 处理单个 ResourceSlot
|
||||
@@ -2032,7 +1907,6 @@ class ROS2DeviceNode:
|
||||
|
||||
if driver_is_ros:
|
||||
driver_params["device_id"] = device_id
|
||||
driver_params["registry_name"] = device_config.res_content.klass
|
||||
driver_params["resource_tracker"] = self.resource_tracker
|
||||
self._driver_instance = self._driver_creator.create_instance(driver_params)
|
||||
if self._driver_instance is None:
|
||||
@@ -2050,7 +1924,6 @@ class ROS2DeviceNode:
|
||||
children=children,
|
||||
driver_instance=self._driver_instance, # type: ignore
|
||||
device_id=device_id,
|
||||
registry_name=device_config.res_content.klass,
|
||||
device_uuid=device_uuid,
|
||||
status_types=status_types,
|
||||
action_value_mappings=action_value_mappings,
|
||||
@@ -2062,7 +1935,6 @@ class ROS2DeviceNode:
|
||||
self._ros_node = BaseROS2DeviceNode(
|
||||
driver_instance=self._driver_instance,
|
||||
device_id=device_id,
|
||||
registry_name=device_config.res_content.klass,
|
||||
device_uuid=device_uuid,
|
||||
status_types=status_types,
|
||||
action_value_mappings=action_value_mappings,
|
||||
@@ -2071,7 +1943,6 @@ class ROS2DeviceNode:
|
||||
resource_tracker=self.resource_tracker,
|
||||
)
|
||||
self._ros_node: BaseROS2DeviceNode
|
||||
# 将注册表类型名传递给BaseROS2DeviceNode,用于slave上报
|
||||
self._ros_node.lab_logger().info(f"初始化完成 {self._ros_node.uuid} {self.driver_is_ros}")
|
||||
self.driver_instance._ros_node = self._ros_node # type: ignore
|
||||
self.driver_instance._execute_driver_command = self._ros_node._execute_driver_command # type: ignore
|
||||
@@ -2089,9 +1960,7 @@ class ROS2DeviceNode:
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_forever()
|
||||
|
||||
ROS2DeviceNode._asyncio_loop_thread = threading.Thread(
|
||||
target=run_event_loop, daemon=True, name="ROS2DeviceNode"
|
||||
)
|
||||
ROS2DeviceNode._asyncio_loop_thread = threading.Thread(target=run_event_loop, daemon=True, name="ROS2DeviceNode")
|
||||
ROS2DeviceNode._asyncio_loop_thread.start()
|
||||
logger.info(f"循环线程已启动")
|
||||
|
||||
|
||||
@@ -6,13 +6,12 @@ from cv_bridge import CvBridge
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeResourceTracker
|
||||
|
||||
class VideoPublisher(BaseROS2DeviceNode):
|
||||
def __init__(self, device_id='video_publisher', registry_name="", device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
|
||||
def __init__(self, device_id='video_publisher', device_uuid='', camera_index=0, period: float = 0.1, resource_tracker: DeviceNodeResourceTracker = None):
|
||||
# 初始化BaseROS2DeviceNode,使用自身作为driver_instance
|
||||
BaseROS2DeviceNode.__init__(
|
||||
self,
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
device_uuid=device_uuid,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
|
||||
@@ -10,7 +10,6 @@ class ControllerNode(BaseROS2DeviceNode):
|
||||
def __init__(
|
||||
self,
|
||||
device_id: str,
|
||||
registry_name: str,
|
||||
controller_func: Callable,
|
||||
update_rate: float,
|
||||
inputs: Dict[str, Dict[str, type | str]],
|
||||
@@ -52,7 +51,6 @@ class ControllerNode(BaseROS2DeviceNode):
|
||||
self,
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
status_types=status_types,
|
||||
action_value_mappings=action_value_mappings,
|
||||
hardware_interface=hardware_interface,
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import collections
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, Union
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from action_msgs.msg import GoalStatus
|
||||
from geometry_msgs.msg import Point
|
||||
from rclpy.action import ActionClient, get_action_server_names_and_types_by_node
|
||||
from rclpy.service import Service
|
||||
from typing_extensions import TypedDict
|
||||
from unilabos_msgs.msg import Resource # type: ignore
|
||||
from unilabos_msgs.srv import (
|
||||
ResourceAdd,
|
||||
@@ -23,20 +23,10 @@ from unilabos_msgs.srv import (
|
||||
from unilabos_msgs.srv._serial_command import SerialCommand_Request, SerialCommand_Response
|
||||
from unique_identifier_msgs.msg import UUID
|
||||
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
from unilabos.registry.registry import lab_registry
|
||||
from unilabos.resources.container import RegularContainer
|
||||
from unilabos.resources.graphio import initialize_resource
|
||||
from unilabos.resources.registry import add_schema
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
RETURN_UNILABOS_SAMPLES,
|
||||
JSON_UNILABOS_PARAM,
|
||||
PARAM_SAMPLE_UUIDS, SampleUUIDsType, LabSample,
|
||||
)
|
||||
from unilabos.ros.initialize_device import initialize_device_from_dict
|
||||
from unilabos.ros.msgs.message_converter import (
|
||||
get_msg_type,
|
||||
@@ -47,11 +37,17 @@ from unilabos.ros.msgs.message_converter import (
|
||||
)
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, ROS2DeviceNode, DeviceNodeResourceTracker
|
||||
from unilabos.ros.nodes.presets.controller_node import ControllerNode
|
||||
from unilabos.resources.resource_tracker import (
|
||||
ResourceDict,
|
||||
ResourceDictInstance,
|
||||
ResourceTreeSet,
|
||||
ResourceTreeInstance,
|
||||
)
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.exception import DeviceClassInvalid
|
||||
from unilabos.utils.log import warning
|
||||
from unilabos.utils.type_check import serialize_result_info
|
||||
from unilabos.config.config import BasicConfig
|
||||
from unilabos.registry.placeholder_type import ResourceSlot, DeviceSlot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unilabos.app.ws_client import QueueItem
|
||||
@@ -64,8 +60,7 @@ class DeviceActionStatus:
|
||||
|
||||
class TestResourceReturn(TypedDict):
|
||||
resources: List[List[ResourceDict]]
|
||||
devices: List[Dict[str, Any]]
|
||||
unilabos_samples: List[LabSample]
|
||||
devices: List[DeviceSlot]
|
||||
|
||||
|
||||
class TestLatencyReturn(TypedDict):
|
||||
@@ -250,7 +245,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self,
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name="host_node",
|
||||
device_uuid=host_node_dict["uuid"],
|
||||
status_types={},
|
||||
action_value_mappings=lab_registry.device_type_registry["host_node"]["class"]["action_value_mappings"],
|
||||
@@ -305,8 +299,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
} # 用来存储多个ActionClient实例
|
||||
self._action_value_mappings: Dict[str, Dict] = (
|
||||
{}
|
||||
) # device_id -> action_value_mappings(本地+远程设备统一存储)
|
||||
self._slave_registry_configs: Dict[str, Dict] = {} # registry_name -> registry_config(含action_value_mappings)
|
||||
) # 用来存储多个ActionClient的type, goal, feedback, result的变量名映射关系
|
||||
self._goals: Dict[str, Any] = {} # 用来存储多个目标的状态
|
||||
self._online_devices: Set[str] = {f"{self.namespace}/{device_id}"} # 用于跟踪在线设备
|
||||
self._last_discovery_time = 0.0 # 上次设备发现的时间
|
||||
@@ -640,8 +633,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.device_machine_names[device_id] = "本地"
|
||||
self.devices_instances[device_id] = d
|
||||
# noinspection PyProtectedMember
|
||||
self._action_value_mappings[device_id] = d._ros_node._action_value_mappings
|
||||
# noinspection PyProtectedMember
|
||||
for action_name, action_value_mapping in d._ros_node._action_value_mappings.items():
|
||||
if action_name.startswith("auto-") or str(action_value_mapping.get("type", "")).startswith(
|
||||
"UniLabJsonCommand"
|
||||
@@ -764,7 +755,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
item: "QueueItem",
|
||||
action_type: str,
|
||||
action_kwargs: Dict[str, Any],
|
||||
sample_material: Dict[str, str],
|
||||
server_info: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -778,29 +768,18 @@ class HostNode(BaseROS2DeviceNode):
|
||||
u = uuid.UUID(item.job_id)
|
||||
device_id = item.device_id
|
||||
action_name = item.action_name
|
||||
|
||||
if BasicConfig.test_mode:
|
||||
action_id = f"/devices/{device_id}/{action_name}"
|
||||
self.lab_logger().info(
|
||||
f"[TEST MODE] 模拟执行: {action_id} (job={item.job_id[:8]}), 参数: {str(action_kwargs)[:500]}"
|
||||
)
|
||||
# 根据注册表 handles 构建模拟返回值
|
||||
mock_return = self._build_test_mode_return(device_id, action_name, action_kwargs)
|
||||
self._handle_test_mode_result(item, action_id, mock_return)
|
||||
return
|
||||
|
||||
if action_type.startswith("UniLabJsonCommand"):
|
||||
if action_name.startswith("auto-"):
|
||||
action_name = action_name[5:]
|
||||
action_id = f"/devices/{device_id}/_execute_driver_command"
|
||||
json_command: Dict[str, Any] = {
|
||||
"function_name": action_name,
|
||||
"function_args": action_kwargs,
|
||||
JSON_UNILABOS_PARAM: {
|
||||
PARAM_SAMPLE_UUIDS: sample_material,
|
||||
},
|
||||
action_kwargs = {
|
||||
"string": json.dumps(
|
||||
{
|
||||
"function_name": action_name,
|
||||
"function_args": action_kwargs,
|
||||
}
|
||||
)
|
||||
}
|
||||
action_kwargs = {"string": json.dumps(json_command)}
|
||||
if action_type.startswith("UniLabJsonCommandAsync"):
|
||||
action_id = f"/devices/{device_id}/_execute_driver_command_async"
|
||||
else:
|
||||
@@ -811,6 +790,21 @@ class HostNode(BaseROS2DeviceNode):
|
||||
raise ValueError(f"ActionClient {action_id} not found.")
|
||||
|
||||
action_client: ActionClient = self._action_clients[action_id]
|
||||
|
||||
# 遍历action_kwargs下的所有子dict,将"sample_uuid"的值赋给"sample_id"
|
||||
def assign_sample_id(obj):
|
||||
if isinstance(obj, dict):
|
||||
if "sample_uuid" in obj:
|
||||
obj["sample_id"] = obj["sample_uuid"]
|
||||
obj.pop("sample_uuid")
|
||||
for k, v in obj.items():
|
||||
if k != "unilabos_extra":
|
||||
assign_sample_id(v)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
assign_sample_id(item)
|
||||
|
||||
assign_sample_id(action_kwargs)
|
||||
goal_msg = convert_to_ros_msg(action_client._action_type.Goal(), action_kwargs)
|
||||
|
||||
# self.lab_logger().trace(f"[Host Node] Sending goal for {action_id}: {str(goal_msg)[:1000]}")
|
||||
@@ -826,51 +820,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
)
|
||||
future.add_done_callback(lambda f: self.goal_response_callback(item, action_id, f))
|
||||
|
||||
def _build_test_mode_return(
|
||||
self, device_id: str, action_name: str, action_kwargs: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
根据注册表 handles 的 output 定义构建测试模式的模拟返回值
|
||||
|
||||
根据 data_key 中 @flatten 的层数决定嵌套数组层数,叶子值为空字典。
|
||||
例如: "vessel" → {}, "plate.@flatten" → [{}], "a.@flatten.@flatten" → [[{}]]
|
||||
"""
|
||||
mock_return: Dict[str, Any] = {"test_mode": True, "action_name": action_name}
|
||||
action_mappings = self._action_value_mappings.get(device_id, {})
|
||||
action_mapping = action_mappings.get(action_name, {})
|
||||
handles = action_mapping.get("handles", {})
|
||||
if isinstance(handles, dict):
|
||||
for output_handle in handles.get("output", []):
|
||||
data_key = output_handle.get("data_key", "")
|
||||
handler_key = output_handle.get("handler_key", "")
|
||||
# 根据 @flatten 层数构建嵌套数组,叶子为空字典
|
||||
flatten_count = data_key.count("@flatten")
|
||||
value: Any = {}
|
||||
for _ in range(flatten_count):
|
||||
value = [value]
|
||||
mock_return[handler_key] = value
|
||||
return mock_return
|
||||
|
||||
def _handle_test_mode_result(
|
||||
self, item: "QueueItem", action_id: str, mock_return: Dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
测试模式下直接构建结果并走正常的结果回调流程(跳过 ROS)
|
||||
"""
|
||||
job_id = item.job_id
|
||||
status = "success"
|
||||
return_info = serialize_result_info("", True, mock_return)
|
||||
|
||||
self.lab_logger().info(f"[TEST MODE] Result for {action_id} ({job_id[:8]}): {status}")
|
||||
|
||||
from unilabos.app.web.controller import store_job_result
|
||||
store_job_result(job_id, status, return_info, mock_return)
|
||||
|
||||
# 发布状态到桥接器
|
||||
for bridge in self.bridges:
|
||||
if hasattr(bridge, "publish_job_status"):
|
||||
bridge.publish_job_status(mock_return, item, status, return_info)
|
||||
|
||||
def goal_response_callback(self, item: "QueueItem", action_id: str, future) -> None:
|
||||
"""目标响应回调"""
|
||||
goal_handle = future.result()
|
||||
@@ -918,14 +867,14 @@ class HostNode(BaseROS2DeviceNode):
|
||||
# 适配后端的一些额外处理
|
||||
return_value = return_info.get("return_value")
|
||||
if isinstance(return_value, dict):
|
||||
unilabos_samples = return_value.pop(RETURN_UNILABOS_SAMPLES, None)
|
||||
unilabos_samples = return_value.pop("unilabos_samples", None)
|
||||
if isinstance(unilabos_samples, list) and unilabos_samples:
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Job {job_id[:8]} returned {len(unilabos_samples)} sample(s): "
|
||||
f"{[s.get('name', s.get('id', 'unknown')) if isinstance(s, dict) else str(s)[:20] for s in unilabos_samples[:5]]}"
|
||||
f"{'...' if len(unilabos_samples) > 5 else ''}"
|
||||
)
|
||||
return_info["samples"] = unilabos_samples
|
||||
return_info["unilabos_samples"] = unilabos_samples
|
||||
suc = return_info.get("suc", False)
|
||||
if not suc:
|
||||
status = "failed"
|
||||
@@ -1195,7 +1144,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.lab_logger().info(f"[Host Node-Resource] UUID映射: {len(uuid_mapping)} 个节点")
|
||||
# 还需要加入到资源图中,暂不实现,考虑资源图新的获取方式
|
||||
response.response = json.dumps(uuid_mapping)
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree update completed, success: {success}")
|
||||
self.lab_logger().info(f"[Host Node-Resource] Resource tree add completed, success: {success}")
|
||||
|
||||
async def _resource_tree_update_callback(self, request: SerialCommand_Request, response: SerialCommand_Response):
|
||||
"""
|
||||
@@ -1230,10 +1179,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
def _node_info_update_callback(self, request, response):
|
||||
"""
|
||||
更新节点信息回调
|
||||
|
||||
处理两种消息:
|
||||
1. 首次上报(main_slave_run): 带 devices_config + registry_config,存储 action_value_mappings
|
||||
2. 设备重注册(SYNC_SLAVE_NODE_INFO): 带 edge_device_id + registry_name,用 registry_name 索引已存储的 mappings
|
||||
"""
|
||||
self.lab_logger().trace(f"[Host Node] Node info update request received: {request}")
|
||||
try:
|
||||
@@ -1245,48 +1190,12 @@ class HostNode(BaseROS2DeviceNode):
|
||||
info = info["SYNC_SLAVE_NODE_INFO"]
|
||||
machine_name = info["machine_name"]
|
||||
edge_device_id = info["edge_device_id"]
|
||||
registry_name = info.get("registry_name", "")
|
||||
self.device_machine_names[edge_device_id] = machine_name
|
||||
|
||||
# 用 registry_name 索引已存储的 registry_config,获取 action_value_mappings
|
||||
if registry_name and registry_name in self._slave_registry_configs:
|
||||
action_mappings = self._slave_registry_configs[registry_name].get(
|
||||
"class", {}
|
||||
).get("action_value_mappings", {})
|
||||
if action_mappings:
|
||||
self._action_value_mappings[edge_device_id] = action_mappings
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Loaded {len(action_mappings)} action mappings "
|
||||
f"for remote device {edge_device_id} (registry: {registry_name})"
|
||||
)
|
||||
else:
|
||||
devices_config = info.pop("devices_config")
|
||||
registry_config = info.pop("registry_config")
|
||||
if registry_config:
|
||||
http_client.resource_registry({"resources": registry_config})
|
||||
|
||||
# 存储 slave 的 registry_config,用于后续 SYNC_SLAVE_NODE_INFO 索引
|
||||
for reg_name, reg_data in registry_config.items():
|
||||
if isinstance(reg_data, dict) and "class" in reg_data:
|
||||
self._slave_registry_configs[reg_name] = reg_data
|
||||
|
||||
# 解析 devices_config,建立 device_id -> action_value_mappings 映射
|
||||
if devices_config:
|
||||
for device_tree in devices_config:
|
||||
for device_dict in device_tree:
|
||||
device_id = device_dict.get("id", "")
|
||||
class_name = device_dict.get("class", "")
|
||||
if device_id and class_name and class_name in self._slave_registry_configs:
|
||||
action_mappings = self._slave_registry_configs[class_name].get(
|
||||
"class", {}
|
||||
).get("action_value_mappings", {})
|
||||
if action_mappings:
|
||||
self._action_value_mappings[device_id] = action_mappings
|
||||
self.lab_logger().info(
|
||||
f"[Host Node] Stored {len(action_mappings)} action mappings "
|
||||
f"for remote device {device_id} (class: {class_name})"
|
||||
)
|
||||
|
||||
self.lab_logger().debug(f"[Host Node] Node info update: {info}")
|
||||
response.response = "OK"
|
||||
except Exception as e:
|
||||
@@ -1583,7 +1492,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
def test_resource(
|
||||
self,
|
||||
sample_uuids: SampleUUIDsType,
|
||||
resource: ResourceSlot = None,
|
||||
resources: List[ResourceSlot] = None,
|
||||
device: DeviceSlot = None,
|
||||
@@ -1598,7 +1506,6 @@ class HostNode(BaseROS2DeviceNode):
|
||||
return {
|
||||
"resources": ResourceTreeSet.from_plr_resources([resource, *resources], known_newly_created=True).dump(),
|
||||
"devices": [device, *devices],
|
||||
"unilabos_samples": [LabSample(sample_uuid=sample_uuid, oss_path="", extra={"material_uuid": content} if isinstance(content, str) else content.serialize()) for sample_uuid, content in sample_uuids.items()]
|
||||
}
|
||||
|
||||
def handle_pong_response(self, pong_data: dict):
|
||||
|
||||
@@ -7,11 +7,10 @@ from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode
|
||||
|
||||
class JointRepublisher(BaseROS2DeviceNode):
|
||||
def __init__(self,device_id, registry_name, resource_tracker, **kwargs):
|
||||
def __init__(self,device_id,resource_tracker, **kwargs):
|
||||
super().__init__(
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
hardware_interface={},
|
||||
|
||||
@@ -23,48 +23,30 @@ from unilabos_msgs.action import SendCmd
|
||||
from rclpy.action.server import ServerGoalHandle
|
||||
from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode,DeviceNodeResourceTracker
|
||||
from unilabos.resources.graphio import initialize_resources
|
||||
from unilabos.resources.resource_tracker import EXTRA_CLASS
|
||||
from unilabos.registry.registry import lab_registry
|
||||
|
||||
class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
def __init__(
|
||||
self,
|
||||
resource_model: Optional[dict] = None,
|
||||
resource_config: Optional[list] = None,
|
||||
resource_tracker=None,
|
||||
device_id: str = "resource_mesh_manager",
|
||||
registry_name: str = "",
|
||||
rate=50,
|
||||
**kwargs,
|
||||
):
|
||||
def __init__(self, resource_model: dict, resource_config: list,resource_tracker, device_id: str = "resource_mesh_manager", rate=50, **kwargs):
|
||||
"""初始化资源网格管理器节点
|
||||
|
||||
|
||||
Args:
|
||||
resource_model: 资源模型字典(可选,为 None 时自动从 registry 构建)
|
||||
resource_config: 资源配置列表(可选,为 None 时启动后通过 ActionServer 或 load_from_resource_tree 加载)
|
||||
resource_tracker: 资源追踪器
|
||||
device_id: 节点名称
|
||||
rate: TF 发布频率
|
||||
resource_model (dict): 资源模型字典,包含资源的3D模型信息
|
||||
resource_config (dict): 资源配置字典,包含资源的配置信息
|
||||
device_id (str): 节点名称
|
||||
"""
|
||||
if resource_tracker is None:
|
||||
resource_tracker = DeviceNodeResourceTracker()
|
||||
|
||||
super().__init__(
|
||||
driver_instance=self,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
hardware_interface={},
|
||||
print_publish=False,
|
||||
resource_tracker=resource_tracker,
|
||||
resource_tracker=resource_tracker,
|
||||
device_uuid=kwargs.get("uuid", str(uuid.uuid4())),
|
||||
)
|
||||
)
|
||||
|
||||
self.resource_model = resource_model if resource_model is not None else {}
|
||||
self.resource_config_dict = (
|
||||
{item['uuid']: item for item in resource_config} if resource_config else {}
|
||||
)
|
||||
self.resource_model = resource_model
|
||||
self.resource_config_dict = {item['uuid']: item for item in resource_config}
|
||||
self.move_group_ready = False
|
||||
self.resource_tf_dict = {}
|
||||
self.tf_broadcaster = TransformBroadcaster(self)
|
||||
@@ -80,7 +62,7 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
self.mesh_path = Path(__file__).parent.parent.parent.parent.absolute()
|
||||
self.msg_type = 'resource_status'
|
||||
self.resource_status_dict = {}
|
||||
|
||||
|
||||
callback_group = ReentrantCallbackGroup()
|
||||
self._get_planning_scene_service = self.create_client(
|
||||
srv_type=GetPlanningScene,
|
||||
@@ -93,7 +75,8 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
),
|
||||
callback_group=callback_group,
|
||||
)
|
||||
|
||||
|
||||
# Create a service for applying the planning scene
|
||||
self._apply_planning_scene_service = self.create_client(
|
||||
srv_type=ApplyPlanningScene,
|
||||
srv_name="/apply_planning_scene",
|
||||
@@ -119,36 +102,27 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
AttachedCollisionObject, "/attached_collision_object", 0
|
||||
)
|
||||
|
||||
# 创建一个Action Server用于修改resource_tf_dict
|
||||
self._action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"tf_update",
|
||||
self.tf_update,
|
||||
callback_group=callback_group,
|
||||
callback_group=callback_group
|
||||
)
|
||||
|
||||
# 创建一个Action Server用于添加新的资源模型与resource_tf_dict
|
||||
self._add_resource_mesh_action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"add_resource_mesh",
|
||||
self.add_resource_mesh_callback,
|
||||
callback_group=callback_group,
|
||||
callback_group=callback_group
|
||||
)
|
||||
|
||||
self._reload_resource_mesh_action_server = ActionServer(
|
||||
self,
|
||||
SendCmd,
|
||||
f"reload_resource_mesh",
|
||||
self._reload_resource_mesh_callback,
|
||||
callback_group=callback_group,
|
||||
)
|
||||
|
||||
if self.resource_config_dict:
|
||||
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict)
|
||||
else:
|
||||
self.get_logger().info("未提供 resource_config,将通过 ActionServer 或 load_from_resource_tree 加载")
|
||||
self.create_timer(1 / self.rate, self.publish_resource_tf)
|
||||
self.create_timer(1 / self.rate, self.check_resource_pose_changes)
|
||||
self.resource_tf_dict = self.resource_mesh_setup(self.resource_config_dict)
|
||||
self.create_timer(1/self.rate, self.publish_resource_tf)
|
||||
self.create_timer(1/self.rate, self.check_resource_pose_changes)
|
||||
|
||||
def check_move_group_ready(self):
|
||||
"""检查move_group节点是否已初始化完成"""
|
||||
@@ -165,156 +139,56 @@ class ResourceMeshManager(BaseROS2DeviceNode):
|
||||
self.add_resource_collision_meshes(self.resource_tf_dict)
|
||||
|
||||
|
||||
def _build_resource_model_for_config(self, resource_config_dict: dict):
|
||||
"""从 registry 中为给定的资源配置自动构建 resource_model(mesh 信息)"""
|
||||
registry = lab_registry
|
||||
for _uuid, res_cfg in resource_config_dict.items():
|
||||
resource_id = res_cfg.get('id', '')
|
||||
resource_class = res_cfg.get('class', '')
|
||||
if not resource_class:
|
||||
continue
|
||||
if resource_class not in registry.resource_type_registry:
|
||||
continue
|
||||
reg_entry = registry.resource_type_registry[resource_class]
|
||||
if 'model' not in reg_entry:
|
||||
continue
|
||||
model_config = reg_entry['model']
|
||||
if model_config.get('type') != 'resource':
|
||||
continue
|
||||
if resource_id in self.resource_model:
|
||||
continue
|
||||
self.resource_model[resource_id] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf'],
|
||||
}
|
||||
if model_config.get('children_mesh') is not None:
|
||||
self.resource_model[f"{resource_id}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf'],
|
||||
}
|
||||
|
||||
def load_from_resource_tree(self):
|
||||
"""从 resource_tracker 中读取资源树,自动构建 resource_config_dict / resource_model 并刷新 TF"""
|
||||
new_config_dict: dict = {}
|
||||
|
||||
def _collect_plr_resource(res, parent_uuid: Optional[str] = None):
|
||||
res_uuid = getattr(res, 'unilabos_uuid', None)
|
||||
if not res_uuid:
|
||||
res_uuid = str(uuid.uuid4())
|
||||
extra = getattr(res, 'unilabos_extra', {}) or {}
|
||||
resource_class = extra.get(EXTRA_CLASS, '')
|
||||
|
||||
location = getattr(res, 'location', None)
|
||||
pos_x = float(location.x) if location else 0.0
|
||||
pos_y = float(location.y) if location else 0.0
|
||||
pos_z = float(location.z) if location else 0.0
|
||||
|
||||
rotation = extra.get('rotation', {'x': 0, 'y': 0, 'z': 0})
|
||||
|
||||
new_config_dict[res_uuid] = {
|
||||
'id': res.name,
|
||||
'uuid': res_uuid,
|
||||
'class': resource_class,
|
||||
'parent_uuid': parent_uuid,
|
||||
'pose': {
|
||||
'position': {'x': pos_x, 'y': pos_y, 'z': pos_z},
|
||||
'rotation': rotation,
|
||||
},
|
||||
}
|
||||
|
||||
for child in getattr(res, 'children', []) or []:
|
||||
_collect_plr_resource(child, res_uuid)
|
||||
|
||||
for resource in self.resource_tracker.resources:
|
||||
root_parent_uuid = None
|
||||
plr_parent = getattr(resource, 'parent', None)
|
||||
if plr_parent is not None:
|
||||
root_parent_uuid = getattr(plr_parent, 'unilabos_uuid', None)
|
||||
_collect_plr_resource(resource, root_parent_uuid)
|
||||
|
||||
if not new_config_dict:
|
||||
self.get_logger().warning("resource_tracker 中没有找到任何资源")
|
||||
return
|
||||
|
||||
self.resource_config_dict = {**self.resource_config_dict, **new_config_dict}
|
||||
self._build_resource_model_for_config(new_config_dict)
|
||||
|
||||
tf_dict = self.resource_mesh_setup(new_config_dict)
|
||||
self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
|
||||
self.publish_resource_tf()
|
||||
if self.move_group_ready:
|
||||
self.add_resource_collision_meshes(tf_dict)
|
||||
self.get_logger().info(f"从资源树加载了 {len(new_config_dict)} 个资源")
|
||||
|
||||
def _reload_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
|
||||
"""ActionServer 回调:重新从资源树加载所有 mesh"""
|
||||
try:
|
||||
self.load_from_resource_tree()
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"重新加载资源失败: {e}")
|
||||
goal_handle.abort()
|
||||
return SendCmd.Result(success=False)
|
||||
goal_handle.succeed()
|
||||
return SendCmd.Result(success=True)
|
||||
|
||||
def add_resource_mesh_callback(self, goal_handle: ServerGoalHandle):
|
||||
def add_resource_mesh_callback(self, goal_handle : ServerGoalHandle):
|
||||
tf_update_msg = goal_handle.request
|
||||
try:
|
||||
parsed = json.loads(tf_update_msg.command.replace("'", '"'))
|
||||
if 'resources' in parsed:
|
||||
for res_config in parsed['resources']:
|
||||
self.add_resource_mesh(json.dumps(res_config))
|
||||
else:
|
||||
self.add_resource_mesh(tf_update_msg.command)
|
||||
try:
|
||||
self.add_resource_mesh(tf_update_msg.command)
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"添加资源失败: {e}")
|
||||
goal_handle.abort()
|
||||
return SendCmd.Result(success=False)
|
||||
goal_handle.succeed()
|
||||
return SendCmd.Result(success=True)
|
||||
|
||||
def add_resource_mesh(self,resource_config_str:str):
|
||||
"""刷新资源配置"""
|
||||
|
||||
def add_resource_mesh(self, resource_config_str: str):
|
||||
"""添加单个资源的 mesh 配置"""
|
||||
registry = lab_registry
|
||||
resource_config = json.loads(resource_config_str.replace("'", '"'))
|
||||
|
||||
resource_config = json.loads(resource_config_str.replace("'",'"'))
|
||||
|
||||
if resource_config['id'] in self.resource_config_dict:
|
||||
self.get_logger().info(f'资源 {resource_config["id"]} 已存在')
|
||||
return
|
||||
resource_class = resource_config.get('class', '')
|
||||
if resource_class and resource_class in registry.resource_type_registry:
|
||||
reg_entry = registry.resource_type_registry[resource_class]
|
||||
if 'model' in reg_entry:
|
||||
model_config = reg_entry['model']
|
||||
if model_config.get('type') == 'resource':
|
||||
self.resource_model[resource_config['id']] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf'],
|
||||
if resource_config['class'] in registry.resource_type_registry.keys():
|
||||
model_config = registry.resource_type_registry[resource_config['class']]['model']
|
||||
if model_config['type'] == 'resource':
|
||||
self.resource_model[resource_config['id']] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['mesh']}",
|
||||
'mesh_tf': model_config['mesh_tf']}
|
||||
if 'children_mesh' in model_config.keys():
|
||||
self.resource_model[f"{resource_config['id']}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf']
|
||||
}
|
||||
if model_config.get('children_mesh') is not None:
|
||||
self.resource_model[f"{resource_config['id']}_"] = {
|
||||
'mesh': f"{str(self.mesh_path)}/device_mesh/resources/{model_config['children_mesh']}",
|
||||
'mesh_tf': model_config['children_mesh_tf'],
|
||||
}
|
||||
resources = initialize_resources([resource_config])
|
||||
resource_dict = {item['id']: item for item in resources}
|
||||
self.resource_config_dict = {**self.resource_config_dict, **resource_dict}
|
||||
self.resource_config_dict = {**self.resource_config_dict,**resource_dict}
|
||||
tf_dict = self.resource_mesh_setup(resource_dict)
|
||||
self.resource_tf_dict = {**self.resource_tf_dict, **tf_dict}
|
||||
self.resource_tf_dict = {**self.resource_tf_dict,**tf_dict}
|
||||
self.publish_resource_tf()
|
||||
self.add_resource_collision_meshes(tf_dict)
|
||||
|
||||
def resource_mesh_setup(self, resource_config_dict: dict):
|
||||
"""根据资源配置字典设置 TF 关系"""
|
||||
|
||||
def resource_mesh_setup(self, resource_config_dict:dict):
|
||||
"""move_group初始化完成后的设置"""
|
||||
self.get_logger().info('开始设置资源网格管理器')
|
||||
#遍历resource_config中的资源配置,判断panent是否在resource_model中,
|
||||
resource_tf_dict = {}
|
||||
for resource_uuid, resource_config in resource_config_dict.items():
|
||||
parent = None
|
||||
resource_id = resource_config['id']
|
||||
parent_uuid = resource_config.get('parent_uuid')
|
||||
if parent_uuid is not None and parent_uuid != "":
|
||||
parent_entry = resource_config_dict.get(parent_uuid) or self.resource_config_dict.get(parent_uuid)
|
||||
parent = parent_entry['id'] if parent_entry else None
|
||||
if resource_config['parent_uuid'] is not None and resource_config['parent_uuid'] != "":
|
||||
parent = resource_config_dict[resource_config['parent_uuid']]['id']
|
||||
|
||||
parent_link = 'world'
|
||||
if parent in self.resource_model:
|
||||
|
||||
@@ -7,7 +7,7 @@ from unilabos.ros.nodes.base_device_node import BaseROS2DeviceNode, DeviceNodeRe
|
||||
|
||||
|
||||
class ROS2SerialNode(BaseROS2DeviceNode):
|
||||
def __init__(self, device_id, registry_name, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None):
|
||||
def __init__(self, device_id, port: str, baudrate: int = 9600, resource_tracker: DeviceNodeResourceTracker=None):
|
||||
# 保存属性,以便在调用父类初始化前使用
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
@@ -28,7 +28,6 @@ class ROS2SerialNode(BaseROS2DeviceNode):
|
||||
BaseROS2DeviceNode.__init__(
|
||||
self,
|
||||
driver_instance=self,
|
||||
registry_name=registry_name,
|
||||
device_id=device_id,
|
||||
status_types={},
|
||||
action_value_mappings={},
|
||||
|
||||
@@ -47,7 +47,6 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
*,
|
||||
driver_instance: "WorkstationBase",
|
||||
device_id: str,
|
||||
registry_name: str,
|
||||
device_uuid: str,
|
||||
status_types: Dict[str, Any],
|
||||
action_value_mappings: Dict[str, Any],
|
||||
@@ -63,7 +62,6 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
super().__init__(
|
||||
driver_instance=driver_instance,
|
||||
device_id=device_id,
|
||||
registry_name=registry_name,
|
||||
device_uuid=device_uuid,
|
||||
status_types=status_types,
|
||||
action_value_mappings={**action_value_mappings, **self.protocol_action_mappings},
|
||||
@@ -342,8 +340,6 @@ class ROS2WorkstationNode(BaseROS2DeviceNode):
|
||||
plr = self.resource_tracker.figure_resource({"name": res_name}, try_mode=False)
|
||||
# 获取父资源
|
||||
res = self.resource_tracker.parent_resource(plr)
|
||||
if res is None:
|
||||
res = plr
|
||||
if id(res) not in seen:
|
||||
seen.add(id(res))
|
||||
unique_resources.append(res)
|
||||
|
||||
@@ -52,8 +52,7 @@ class DeviceClassCreator(Generic[T]):
|
||||
if self.device_instance is not None:
|
||||
for c in self.children:
|
||||
if c.res_content.type != "device":
|
||||
res = ResourceTreeSet([ResourceTreeInstance(c)]).to_plr_resources()[0]
|
||||
self.resource_tracker.add_resource(res)
|
||||
self.resource_tracker.add_resource(c.get_plr_nested_dict())
|
||||
|
||||
def create_instance(self, data: Dict[str, Any]) -> T:
|
||||
"""
|
||||
@@ -120,7 +119,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
# return resource, source_type
|
||||
|
||||
def _process_resource_references(
|
||||
self, data: Any, processed_child_names: Optional[Dict[str, Any]], to_dict=False, states=None, prefix_path="", name_to_uuid=None
|
||||
self, data: Any, to_dict=False, states=None, prefix_path="", name_to_uuid=None
|
||||
) -> Any:
|
||||
"""
|
||||
递归处理资源引用,替换_resource_child_name对应的资源
|
||||
@@ -165,7 +164,6 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
states[prefix_path] = resource_instance.serialize_all_state()
|
||||
return serialized
|
||||
else:
|
||||
processed_child_names[child_name] = resource_instance
|
||||
self.resource_tracker.add_resource(resource_instance)
|
||||
# 立即设置UUID,state已经在resource_ulab_to_plr中处理过了
|
||||
if name_to_uuid:
|
||||
@@ -184,12 +182,12 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
new_prefix = f"{prefix_path}.{key}" if prefix_path else key
|
||||
result[key] = self._process_resource_references(value, processed_child_names, to_dict, states, new_prefix, name_to_uuid)
|
||||
result[key] = self._process_resource_references(value, to_dict, states, new_prefix, name_to_uuid)
|
||||
return result
|
||||
|
||||
elif isinstance(data, list):
|
||||
return [
|
||||
self._process_resource_references(item, processed_child_names, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
|
||||
self._process_resource_references(item, to_dict, states, f"{prefix_path}[{i}]", name_to_uuid)
|
||||
for i, item in enumerate(data)
|
||||
]
|
||||
|
||||
@@ -236,7 +234,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
# 首先处理资源引用
|
||||
states = {}
|
||||
processed_data = self._process_resource_references(
|
||||
data, {}, to_dict=True, states=states, name_to_uuid=name_to_uuid
|
||||
data, to_dict=True, states=states, name_to_uuid=name_to_uuid
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -272,12 +270,7 @@ class PyLabRobotCreator(DeviceClassCreator[T]):
|
||||
arg_value = spec_args[param_name].annotation
|
||||
data[param_name]["_resource_type"] = self.device_cls.__module__ + ":" + arg_value
|
||||
logger.debug(f"自动补充 _resource_type: {data[param_name]['_resource_type']}")
|
||||
processed_child_names = {}
|
||||
processed_data = self._process_resource_references(data, processed_child_names, to_dict=False, name_to_uuid=name_to_uuid)
|
||||
for child_name, resource_instance in processed_data.items():
|
||||
for ind, name in enumerate([child.res_content.name for child in self.children]):
|
||||
if name == child_name:
|
||||
self.children.pop(ind)
|
||||
processed_data = self._process_resource_references(data, to_dict=False, name_to_uuid=name_to_uuid)
|
||||
self.device_instance = super(PyLabRobotCreator, self).create_instance(processed_data) # 补全变量后直接调用,调用的自身的attach_resource
|
||||
except Exception as e:
|
||||
logger.error(f"PyLabRobot创建实例失败: {e}")
|
||||
@@ -349,10 +342,9 @@ class WorkstationNodeCreator(DeviceClassCreator[T]):
|
||||
try:
|
||||
# 创建实例,额外补充一个给protocol node的字段,后面考虑取消
|
||||
data["children"] = self.children
|
||||
# super(WorkstationNodeCreator, self).create_instance(data)的时候会attach
|
||||
# for child in self.children:
|
||||
# if child.res_content.type != "device":
|
||||
# self.resource_tracker.add_resource(child.get_plr_nested_dict())
|
||||
for child in self.children:
|
||||
if child.res_content.type != "device":
|
||||
self.resource_tracker.add_resource(child.get_plr_nested_dict())
|
||||
deck_dict = data.get("deck")
|
||||
if deck_dict:
|
||||
from pylabrobot.resources import Deck, Resource
|
||||
|
||||
@@ -339,8 +339,13 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"max_volume": 500.0,
|
||||
"type": "RegularContainer",
|
||||
"category": "container"
|
||||
"category": "container",
|
||||
"max_temp": 200.0,
|
||||
"min_temp": -20.0,
|
||||
"has_stirrer": true,
|
||||
"has_heater": true
|
||||
},
|
||||
"data": {
|
||||
"liquids": [],
|
||||
@@ -764,7 +769,9 @@
|
||||
"size_y": 250,
|
||||
"size_z": 0,
|
||||
"type": "RegularContainer",
|
||||
"category": "container"
|
||||
"category": "container",
|
||||
"reagent": "sodium_chloride",
|
||||
"physical_state": "solid"
|
||||
},
|
||||
"data": {
|
||||
"current_mass": 500.0,
|
||||
@@ -785,11 +792,14 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"volume": 500.0,
|
||||
"size_x": 600,
|
||||
"size_y": 250,
|
||||
"size_z": 0,
|
||||
"type": "RegularContainer",
|
||||
"category": "container"
|
||||
"category": "container",
|
||||
"reagent": "sodium_carbonate",
|
||||
"physical_state": "solid"
|
||||
},
|
||||
"data": {
|
||||
"current_mass": 500.0,
|
||||
@@ -810,11 +820,14 @@
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"volume": 500.0,
|
||||
"size_x": 650,
|
||||
"size_y": 250,
|
||||
"size_z": 0,
|
||||
"type": "RegularContainer",
|
||||
"category": "container"
|
||||
"category": "container",
|
||||
"reagent": "magnesium_chloride",
|
||||
"physical_state": "solid"
|
||||
},
|
||||
"data": {
|
||||
"current_mass": 500.0,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
29
unilabos/test/experiments/xkc_sensor_test.json
Normal file
29
unilabos/test/experiments/xkc_sensor_test.json
Normal 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": []
|
||||
}
|
||||
28
unilabos/test/experiments/zdt_motor_test.json
Normal file
28
unilabos/test/experiments/zdt_motor_test.json
Normal 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": []
|
||||
}
|
||||
@@ -184,51 +184,6 @@ def get_all_subscriptions(instance) -> list:
|
||||
return subscriptions
|
||||
|
||||
|
||||
def always_free(func: F) -> F:
|
||||
"""
|
||||
标记动作为永久闲置(不受busy队列限制)的装饰器
|
||||
|
||||
被此装饰器标记的 action 方法,在执行时不会受到设备级别的排队限制,
|
||||
任何时候请求都可以立即执行。适用于查询类、状态读取类等轻量级操作。
|
||||
|
||||
Example:
|
||||
class MyDriver:
|
||||
@always_free
|
||||
def query_status(self, param: str):
|
||||
# 这个动作可以随时执行,不需要排队
|
||||
return self._status
|
||||
|
||||
def transfer(self, volume: float):
|
||||
# 这个动作会按正常排队逻辑执行
|
||||
pass
|
||||
|
||||
Note:
|
||||
- 可以与其他装饰器组合使用,@always_free 应放在最外层
|
||||
- 仅影响 WebSocket 调度层的 busy/free 判断,不影响 ROS2 层
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
wrapper._is_always_free = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_always_free(func) -> bool:
|
||||
"""
|
||||
检查函数是否被标记为永久闲置
|
||||
|
||||
Args:
|
||||
func: 被检查的函数
|
||||
|
||||
Returns:
|
||||
如果函数被 @always_free 装饰则返回 True,否则返回 False
|
||||
"""
|
||||
return getattr(func, "_is_always_free", False)
|
||||
|
||||
|
||||
def not_action(func: F) -> F:
|
||||
"""
|
||||
标记方法为非动作的装饰器
|
||||
|
||||
@@ -27,9 +27,8 @@ __all__ = [
|
||||
|
||||
from ast import Constant
|
||||
|
||||
from unilabos.resources.resource_tracker import PARAM_SAMPLE_UUIDS
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.decorator import is_not_action, is_always_free
|
||||
from unilabos.utils.decorator import is_not_action
|
||||
|
||||
|
||||
class ImportManager:
|
||||
@@ -282,9 +281,6 @@ class ImportManager:
|
||||
continue
|
||||
# 其他非_开头的方法归类为action
|
||||
method_info = self._analyze_method_signature(method)
|
||||
# 检查是否被 @always_free 装饰器标记
|
||||
if is_always_free(method):
|
||||
method_info["always_free"] = True
|
||||
result["action_methods"][name] = method_info
|
||||
|
||||
return result
|
||||
@@ -342,24 +338,16 @@ class ImportManager:
|
||||
if self._is_not_action_method(node):
|
||||
continue
|
||||
# 其他非_开头的方法归类为action
|
||||
# 检查是否被 @always_free 装饰器标记
|
||||
if self._is_always_free_method(node):
|
||||
method_info["always_free"] = True
|
||||
result["action_methods"][method_name] = method_info
|
||||
return result
|
||||
|
||||
def _analyze_method_signature(self, method, skip_unilabos_params: bool = True) -> Dict[str, Any]:
|
||||
def _analyze_method_signature(self, method) -> Dict[str, Any]:
|
||||
"""
|
||||
分析方法签名,提取具体的命名参数信息
|
||||
|
||||
注意:此方法会跳过*args和**kwargs,只提取具体的命名参数
|
||||
这样可以确保通过**dict方式传参时的准确性
|
||||
|
||||
Args:
|
||||
method: 要分析的方法
|
||||
skip_unilabos_params: 是否跳过 unilabos 系统参数(如 sample_uuids),
|
||||
registry 补全时为 True,JsonCommand 执行时为 False
|
||||
|
||||
示例用法:
|
||||
method_info = self._analyze_method_signature(some_method)
|
||||
params = {"param1": "value1", "param2": "value2"}
|
||||
@@ -380,10 +368,6 @@ class ImportManager:
|
||||
if param.kind == param.VAR_KEYWORD: # **kwargs
|
||||
continue
|
||||
|
||||
# 跳过 sample_uuids 参数(由系统自动注入,registry 补全时跳过)
|
||||
if skip_unilabos_params and param_name == PARAM_SAMPLE_UUIDS:
|
||||
continue
|
||||
|
||||
is_required = param.default == inspect.Parameter.empty
|
||||
if is_required:
|
||||
num_required += 1
|
||||
@@ -480,13 +464,6 @@ class ImportManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_always_free_method(self, node: ast.FunctionDef) -> bool:
|
||||
"""检查是否是@always_free装饰的方法"""
|
||||
for decorator in node.decorator_list:
|
||||
if isinstance(decorator, ast.Name) and decorator.id == "always_free":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_property_name_from_setter(self, node: ast.FunctionDef) -> str:
|
||||
"""从setter装饰器中获取属性名"""
|
||||
for decorator in node.decorator_list:
|
||||
@@ -586,9 +563,6 @@ class ImportManager:
|
||||
for i, arg in enumerate(node.args.args):
|
||||
if arg.arg == "self":
|
||||
continue
|
||||
# 跳过 sample_uuids 参数(由系统自动注入)
|
||||
if arg.arg == PARAM_SAMPLE_UUIDS:
|
||||
continue
|
||||
arg_info = {
|
||||
"name": arg.arg,
|
||||
"type": None,
|
||||
|
||||
@@ -193,7 +193,6 @@ def configure_logger(loglevel=None, working_dir=None):
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 如果指定了工作目录,添加文件处理器
|
||||
log_filepath = None
|
||||
if working_dir is not None:
|
||||
logs_dir = os.path.join(working_dir, "logs")
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
@@ -214,7 +213,6 @@ def configure_logger(loglevel=None, working_dir=None):
|
||||
|
||||
logging.getLogger("asyncio").setLevel(logging.INFO)
|
||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||
return log_filepath
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
res_id: plate_slot_{slot}
|
||||
device_id: /PRCXI
|
||||
class_name: PRCXI_BioER_96_wellplate
|
||||
parent: /PRCXI/PRCXI_Deck
|
||||
parent: /PRCXI/PRCXI_Deck/T{slot}
|
||||
slot_on_deck: "{slot}"
|
||||
- 输出端口: labware(用于连接 set_liquid_from_plate)
|
||||
- 控制流: create_resource 之间通过 ready 端口串联
|
||||
@@ -51,7 +51,6 @@
|
||||
--------------------------------------------------------------------------------
|
||||
- 遍历 workflow 数组,为每个动作创建步骤节点
|
||||
- 参数重命名: asp_vol -> asp_vols, dis_vol -> dis_vols, asp_flow_rate -> asp_flow_rates, dis_flow_rate -> dis_flow_rates
|
||||
- 参数输入转换: liquid_height(按 wells 扩展);mix_stage/mix_times/mix_vol/mix_rate/mix_liquid_height 保持标量
|
||||
- 参数扩展: 根据 targets 的 wells 数量,将单值扩展为数组
|
||||
例: asp_vol=100.0, targets 有 3 个 wells -> asp_vols=[100.0, 100.0, 100.0]
|
||||
- 连接处理: 如果 sources/targets 已通过 set_liquid_from_plate 连接,参数值改为 []
|
||||
@@ -61,11 +60,7 @@
|
||||
==================== 连接关系图 ====================
|
||||
|
||||
控制流 (ready 端口串联):
|
||||
- create_resource 之间: 无 ready 连接
|
||||
- set_liquid_from_plate 之间: 无 ready 连接
|
||||
- create_resource 与 set_liquid_from_plate 之间: 无 ready 连接
|
||||
- transfer_liquid 之间: 通过 ready 端口串联
|
||||
transfer_liquid_1 -> transfer_liquid_2 -> transfer_liquid_3 -> ...
|
||||
create_resource_1 -> create_resource_2 -> ... -> set_liquid_1 -> set_liquid_2 -> ... -> transfer_liquid_1 -> transfer_liquid_2 -> ...
|
||||
|
||||
物料流:
|
||||
[create_resource] --labware--> [set_liquid_from_plate] --output_wells--> [transfer_liquid] --sources_out/targets_out--> [下一个 transfer_liquid]
|
||||
@@ -120,14 +115,11 @@ DEVICE_NAME_DEFAULT = "PRCXI" # transfer_liquid, set_liquid_from_plate 等动
|
||||
# 节点类型
|
||||
NODE_TYPE_DEFAULT = "ILab" # 所有节点的默认类型
|
||||
|
||||
CLASS_NAMES_MAPPING = {
|
||||
"plate": "PRCXI_BioER_96_wellplate",
|
||||
"tip_rack": "PRCXI_300ul_Tips",
|
||||
}
|
||||
# create_resource 节点默认参数
|
||||
CREATE_RESOURCE_DEFAULTS = {
|
||||
"device_id": "/PRCXI",
|
||||
"parent_template": "/PRCXI/PRCXI_Deck",
|
||||
"parent_template": "/PRCXI/PRCXI_Deck/T{slot}", # {slot} 会被替换为实际的 slot 值
|
||||
"class_name": "PRCXI_BioER_96_wellplate",
|
||||
}
|
||||
|
||||
# 默认液体体积 (uL)
|
||||
@@ -366,7 +358,6 @@ def build_protocol_graph(
|
||||
protocol_steps: List[Dict[str, Any]],
|
||||
workstation_name: str,
|
||||
action_resource_mapping: Optional[Dict[str, str]] = None,
|
||||
labware_defs: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> WorkflowGraph:
|
||||
"""统一的协议图构建函数,根据设备类型自动选择构建逻辑
|
||||
|
||||
@@ -387,14 +378,11 @@ def build_protocol_graph(
|
||||
slots_info = {} # slot -> {labware, res_id}
|
||||
for labware_id, item in labware_info.items():
|
||||
slot = str(item.get("slot", ""))
|
||||
labware = item.get("labware", "")
|
||||
if slot and slot not in slots_info:
|
||||
res_id = f"{labware}_slot_{slot}"
|
||||
res_id = f"plate_slot_{slot}"
|
||||
slots_info[slot] = {
|
||||
"labware": labware,
|
||||
"labware": item.get("labware", ""),
|
||||
"res_id": res_id,
|
||||
"labware_id": labware_id,
|
||||
"object": item.get("object", ""),
|
||||
}
|
||||
|
||||
# 创建 Group 节点,包含所有 create_resource 节点
|
||||
@@ -412,22 +400,19 @@ def build_protocol_graph(
|
||||
param=None,
|
||||
)
|
||||
|
||||
trash_create_node_id = None # 记录 trash 的 create_resource 节点
|
||||
|
||||
# 为每个唯一的 slot 创建 create_resource 节点
|
||||
res_index = 0
|
||||
last_create_resource_id = None
|
||||
for slot, info in slots_info.items():
|
||||
node_id = str(uuid.uuid4())
|
||||
res_id = info["res_id"]
|
||||
res_type_name = info["labware"].lower().replace(".", "point")
|
||||
object_type = info.get("object", "")
|
||||
res_type_name = f"lab_{res_type_name}"
|
||||
if object_type == "trash":
|
||||
res_type_name = "PRCXI_trash"
|
||||
|
||||
res_index += 1
|
||||
G.add_node(
|
||||
node_id,
|
||||
template_name="create_resource",
|
||||
resource_name="host_node",
|
||||
name=f"{res_type_name}_slot{slot}",
|
||||
name=f"Plate {res_index}",
|
||||
description=f"Create plate on slot {slot}",
|
||||
lab_node_type="Labware",
|
||||
footer="create_resource-host_node",
|
||||
@@ -438,18 +423,18 @@ def build_protocol_graph(
|
||||
param={
|
||||
"res_id": res_id,
|
||||
"device_id": CREATE_RESOURCE_DEFAULTS["device_id"],
|
||||
"class_name": res_type_name,
|
||||
"class_name": CREATE_RESOURCE_DEFAULTS["class_name"],
|
||||
"parent": CREATE_RESOURCE_DEFAULTS["parent_template"].format(slot=slot),
|
||||
"bind_locations": {"x": 0.0, "y": 0.0, "z": 0.0},
|
||||
"slot_on_deck": slot,
|
||||
},
|
||||
)
|
||||
slot_to_create_resource[slot] = node_id
|
||||
if object_type == "tiprack":
|
||||
resource_last_writer[info["labware_id"]] = f"{node_id}:labware"
|
||||
if object_type == "trash":
|
||||
trash_create_node_id = node_id
|
||||
# create_resource 之间不需要 ready 连接
|
||||
|
||||
# create_resource 之间通过 ready 串联
|
||||
if last_create_resource_id is not None:
|
||||
G.add_edge(last_create_resource_id, node_id, source_port="ready", target_port="ready")
|
||||
last_create_resource_id = node_id
|
||||
|
||||
# ==================== 第二步:为每个 reagent 创建 set_liquid_from_plate 节点 ====================
|
||||
# 创建 Group 节点,包含所有 set_liquid_from_plate 节点
|
||||
@@ -468,6 +453,7 @@ def build_protocol_graph(
|
||||
)
|
||||
|
||||
set_liquid_index = 0
|
||||
last_set_liquid_id = last_create_resource_id # set_liquid_from_plate 连接在 create_resource 之后
|
||||
|
||||
for labware_id, item in labware_info.items():
|
||||
# 跳过 Tip/Rack 类型
|
||||
@@ -484,8 +470,6 @@ def build_protocol_graph(
|
||||
# res_id 不能有空格
|
||||
res_id = str(labware_id).replace(" ", "_")
|
||||
well_count = len(wells)
|
||||
object_type = item.get("object", "")
|
||||
liquid_volume = DEFAULT_LIQUID_VOLUME if object_type == "source" else 0
|
||||
|
||||
node_id = str(uuid.uuid4())
|
||||
set_liquid_index += 1
|
||||
@@ -506,11 +490,14 @@ def build_protocol_graph(
|
||||
"plate": [], # 通过连接传递
|
||||
"well_names": wells, # 孔位名数组,如 ["A1", "A3", "A5"]
|
||||
"liquid_names": [res_id] * well_count,
|
||||
"volumes": [liquid_volume] * well_count,
|
||||
"volumes": [DEFAULT_LIQUID_VOLUME] * well_count,
|
||||
},
|
||||
)
|
||||
|
||||
# set_liquid_from_plate 之间不需要 ready 连接
|
||||
# ready 连接:上一个节点 -> set_liquid_from_plate
|
||||
if last_set_liquid_id is not None:
|
||||
G.add_edge(last_set_liquid_id, node_id, source_port="ready", target_port="ready")
|
||||
last_set_liquid_id = node_id
|
||||
|
||||
# 物料流:create_resource 的 labware -> set_liquid_from_plate 的 input_plate
|
||||
create_res_node_id = slot_to_create_resource.get(slot)
|
||||
@@ -520,8 +507,7 @@ def build_protocol_graph(
|
||||
# set_liquid_from_plate 的输出 output_wells 用于连接 transfer_liquid
|
||||
resource_last_writer[labware_id] = f"{node_id}:output_wells"
|
||||
|
||||
# transfer_liquid 之间通过 ready 串联;若存在 trash 节点,第一个 transfer_liquid 从 trash 的 ready 开始
|
||||
last_control_node_id = trash_create_node_id
|
||||
last_control_node_id = last_set_liquid_id
|
||||
|
||||
# 端口名称映射:JSON 字段名 -> 实际 handle key
|
||||
INPUT_PORT_MAPPING = {
|
||||
@@ -533,7 +519,6 @@ def build_protocol_graph(
|
||||
"reagent": "reagent",
|
||||
"solvent": "solvent",
|
||||
"compound": "compound",
|
||||
"tip_racks": "tip_rack_identifier",
|
||||
}
|
||||
|
||||
OUTPUT_PORT_MAPPING = {
|
||||
@@ -548,17 +533,8 @@ def build_protocol_graph(
|
||||
"compound": "compound",
|
||||
}
|
||||
|
||||
# 需要根据 wells 数量扩展的参数列表:
|
||||
# - 复数参数(asp_vols 等)支持单值自动扩展
|
||||
# - liquid_height 按 wells 扩展为数组
|
||||
# - mix_* 参数保持标量,避免被转换为 list
|
||||
EXPAND_BY_WELLS_PARAMS = [
|
||||
"asp_vols",
|
||||
"dis_vols",
|
||||
"asp_flow_rates",
|
||||
"dis_flow_rates",
|
||||
"liquid_height",
|
||||
]
|
||||
# 需要根据 wells 数量扩展的参数列表(复数形式)
|
||||
EXPAND_BY_WELLS_PARAMS = ["asp_vols", "dis_vols", "asp_flow_rates", "dis_flow_rates"]
|
||||
|
||||
# 处理协议步骤
|
||||
for step in protocol_steps:
|
||||
@@ -572,57 +548,6 @@ def build_protocol_graph(
|
||||
if old_name in params:
|
||||
params[new_name] = params.pop(old_name)
|
||||
|
||||
# touch_tip 输入归一化:
|
||||
# - 支持 bool / 0/1 / "true"/"false" / 单元素 list
|
||||
# - 最终统一为 bool 标量,避免被下游误当作序列处理
|
||||
if "touch_tip" in params:
|
||||
touch_tip_value = params.get("touch_tip")
|
||||
if isinstance(touch_tip_value, list):
|
||||
if len(touch_tip_value) == 1:
|
||||
touch_tip_value = touch_tip_value[0]
|
||||
elif len(touch_tip_value) == 0:
|
||||
touch_tip_value = False
|
||||
else:
|
||||
warnings.append(f"touch_tip 期望标量,但收到长度为 {len(touch_tip_value)} 的列表,使用首个值")
|
||||
touch_tip_value = touch_tip_value[0]
|
||||
if isinstance(touch_tip_value, str):
|
||||
norm = touch_tip_value.strip().lower()
|
||||
if norm in {"true", "1", "yes", "y", "on"}:
|
||||
touch_tip_value = True
|
||||
elif norm in {"false", "0", "no", "n", "off", ""}:
|
||||
touch_tip_value = False
|
||||
else:
|
||||
warnings.append(f"touch_tip 字符串值无法识别: {touch_tip_value},按 True 处理")
|
||||
touch_tip_value = True
|
||||
elif isinstance(touch_tip_value, (int, float)):
|
||||
touch_tip_value = bool(touch_tip_value)
|
||||
elif touch_tip_value is None:
|
||||
touch_tip_value = False
|
||||
else:
|
||||
touch_tip_value = bool(touch_tip_value)
|
||||
params["touch_tip"] = touch_tip_value
|
||||
|
||||
# delays 输入归一化:
|
||||
# - 支持标量(int/float/字符串数字)与 list
|
||||
# - 最终统一为数字列表,供下游按 delays[0]/delays[1] 使用
|
||||
if "delays" in params:
|
||||
delays_value = params.get("delays")
|
||||
if delays_value is None or delays_value == "":
|
||||
params["delays"] = []
|
||||
else:
|
||||
raw_list = delays_value if isinstance(delays_value, list) else [delays_value]
|
||||
normalized_delays = []
|
||||
for delay_item in raw_list:
|
||||
if isinstance(delay_item, str):
|
||||
delay_item = delay_item.strip()
|
||||
if delay_item == "":
|
||||
continue
|
||||
try:
|
||||
normalized_delays.append(float(delay_item))
|
||||
except (TypeError, ValueError):
|
||||
warnings.append(f"delays 包含无法转换为数字的值: {delay_item},已忽略")
|
||||
params["delays"] = normalized_delays
|
||||
|
||||
# 处理输入连接
|
||||
for param_key, target_port in INPUT_PORT_MAPPING.items():
|
||||
resource_name = params.get(param_key)
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"""
|
||||
JSON 工作流转换模块
|
||||
|
||||
将 workflow/reagent/labware 格式的 JSON 转换为统一工作流格式。
|
||||
将 workflow/reagent 格式的 JSON 转换为统一工作流格式。
|
||||
|
||||
输入格式:
|
||||
{
|
||||
"labware": [
|
||||
{"name": "...", "slot": "1", "type": "lab_xxx"},
|
||||
...
|
||||
],
|
||||
"workflow": [
|
||||
{"action": "...", "action_args": {...}},
|
||||
...
|
||||
],
|
||||
"reagent": {
|
||||
"reagent_name": {"slot": int, "well": [...]},
|
||||
"reagent_name": {"slot": int, "well": [...], "labware": "..."},
|
||||
...
|
||||
}
|
||||
}
|
||||
@@ -249,18 +245,18 @@ def convert_from_json(
|
||||
if "workflow" not in json_data or "reagent" not in json_data:
|
||||
raise ValueError(
|
||||
"不支持的 JSON 格式。请使用标准格式:\n"
|
||||
'{"labware": [...], "workflow": [...], "reagent": {...}}'
|
||||
'{"workflow": [{"action": "...", "action_args": {...}}, ...], '
|
||||
'"reagent": {"name": {"slot": int, "well": [...], "labware": "..."}, ...}}'
|
||||
)
|
||||
|
||||
# 提取数据
|
||||
workflow = json_data["workflow"]
|
||||
reagent = json_data["reagent"]
|
||||
labware_defs = json_data.get("labware", []) # 新的 labware 定义列表
|
||||
|
||||
# 规范化步骤数据
|
||||
protocol_steps = normalize_workflow_steps(workflow)
|
||||
|
||||
# reagent 已经是字典格式,用于 set_liquid 和 well 数量查找
|
||||
# reagent 已经是字典格式,直接使用
|
||||
labware_info = reagent
|
||||
|
||||
# 构建工作流图
|
||||
@@ -269,7 +265,6 @@ def convert_from_json(
|
||||
protocol_steps=protocol_steps,
|
||||
workstation_name=workstation_name,
|
||||
action_resource_mapping=ACTION_RESOURCE_MAPPING,
|
||||
labware_defs=labware_defs,
|
||||
)
|
||||
|
||||
# 校验句柄配置
|
||||
|
||||
@@ -41,7 +41,6 @@ def upload_workflow(
|
||||
workflow_name: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
published: bool = False,
|
||||
description: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
上传工作流到服务器
|
||||
@@ -57,7 +56,6 @@ def upload_workflow(
|
||||
workflow_name: 工作流名称,如果不提供则从文件中读取或使用文件名
|
||||
tags: 工作流标签列表,默认为空列表
|
||||
published: 是否发布工作流,默认为False
|
||||
description: 工作流描述,发布时使用
|
||||
|
||||
Returns:
|
||||
Dict: API响应数据
|
||||
@@ -77,14 +75,6 @@ def upload_workflow(
|
||||
print_status(f"工作流文件JSON解析失败: {e}", "error")
|
||||
return {"code": -1, "message": f"JSON解析失败: {e}"}
|
||||
|
||||
# 从 JSON 文件中提取 description 和 tags(作为 fallback)
|
||||
if not description and "description" in workflow_data:
|
||||
description = workflow_data["description"]
|
||||
print_status(f"从文件中读取 description", "info")
|
||||
if not tags and "tags" in workflow_data:
|
||||
tags = workflow_data["tags"]
|
||||
print_status(f"从文件中读取 tags: {tags}", "info")
|
||||
|
||||
# 自动检测并转换格式
|
||||
if not _is_node_link_format(workflow_data):
|
||||
try:
|
||||
@@ -106,7 +96,6 @@ def upload_workflow(
|
||||
print_status(f" - 节点数量: {len(nodes)}", "info")
|
||||
print_status(f" - 边数量: {len(edges)}", "info")
|
||||
print_status(f" - 标签: {tags or []}", "info")
|
||||
print_status(f" - 描述: {description[:50]}{'...' if len(description) > 50 else ''}", "info")
|
||||
print_status(f" - 发布状态: {published}", "info")
|
||||
|
||||
# 调用 http_client 上传
|
||||
@@ -118,7 +107,6 @@ def upload_workflow(
|
||||
edges=edges,
|
||||
tags=tags,
|
||||
published=published,
|
||||
description=description,
|
||||
)
|
||||
|
||||
if result.get("code") == 0:
|
||||
@@ -143,9 +131,8 @@ def handle_workflow_upload_command(args_dict: Dict[str, Any]) -> None:
|
||||
workflow_name = args_dict.get("workflow_name")
|
||||
tags = args_dict.get("tags", [])
|
||||
published = args_dict.get("published", False)
|
||||
description = args_dict.get("description", "")
|
||||
|
||||
if workflow_file:
|
||||
upload_workflow(workflow_file, workflow_name, tags, published, description)
|
||||
upload_workflow(workflow_file, workflow_name, tags, published)
|
||||
else:
|
||||
print_status("未指定工作流文件路径,请使用 -f/--workflow_file 参数", "error")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||
<package format="3">
|
||||
<name>unilabos_msgs</name>
|
||||
<version>0.10.18</version>
|
||||
<version>0.10.17</version>
|
||||
<description>ROS2 Messages package for unilabos devices</description>
|
||||
<maintainer email="changjh@pku.edu.cn">Junhan Chang</maintainer>
|
||||
<maintainer email="18435084+Xuwznln@users.noreply.github.com">Xuwznln</maintainer>
|
||||
|
||||
Reference in New Issue
Block a user