mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2026-02-04 05:15:10 +00:00
Compare commits
3 Commits
64c748d921
...
feat/unila
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7a1d5cdd3 | ||
|
|
263d3bbd22 | ||
|
|
98e706ad8f |
@@ -1,9 +0,0 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM upgrade pip
|
||||
"%PREFIX%\python.exe" -m pip install --upgrade pip
|
||||
|
||||
REM install extra deps
|
||||
"%PREFIX%\python.exe" -m pip install paho-mqtt opentrons_shared_data
|
||||
"%PREFIX%\python.exe" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euxo pipefail
|
||||
|
||||
# make sure pip is available
|
||||
"$PREFIX/bin/python" -m pip install --upgrade pip
|
||||
|
||||
# install extra deps
|
||||
"$PREFIX/bin/python" -m pip install paho-mqtt opentrons_shared_data
|
||||
"$PREFIX/bin/python" -m pip install git+https://github.com/Xuwznln/pylabrobot.git
|
||||
@@ -1,26 +0,0 @@
|
||||
.conda
|
||||
# .github
|
||||
.idea
|
||||
# .vscode
|
||||
output
|
||||
pylabrobot_repo
|
||||
recipes
|
||||
scripts
|
||||
service
|
||||
temp
|
||||
# unilabos/test
|
||||
# unilabos/app/web
|
||||
unilabos/device_mesh
|
||||
unilabos_data
|
||||
unilabos_msgs
|
||||
unilabos.egg-info
|
||||
CONTRIBUTORS
|
||||
# LICENSE
|
||||
MANIFEST.in
|
||||
pyrightconfig.json
|
||||
# README.md
|
||||
# README_zh.md
|
||||
setup.py
|
||||
setup.cfg
|
||||
.gitattrubutes
|
||||
**/__pycache__
|
||||
19
.github/dependabot.yml
vendored
Normal file
19
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
version: 2
|
||||
updates:
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
target-branch: "dev"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "06:00"
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- "msgcenterpy-team"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
52
.github/workflows/ci-check.yml
vendored
Normal file
52
.github/workflows/ci-check.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: CI Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
jobs:
|
||||
registry-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash -l {0}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Miniconda
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
with:
|
||||
miniconda-version: 'latest'
|
||||
channels: conda-forge,robostack-staging,uni-lab,defaults
|
||||
channel-priority: strict
|
||||
activate-environment: check-env
|
||||
auto-activate-base: false
|
||||
auto-update-conda: false
|
||||
show-channel-urls: true
|
||||
|
||||
- name: Install minimal ROS dependencies
|
||||
run: |
|
||||
conda install ros-humble-ros-core ros-humble-std-msgs ros-humble-geometry-msgs ros-humble-control-msgs -c robostack-staging -c conda-forge
|
||||
|
||||
- name: Install unilabos-msgs and project
|
||||
run: |
|
||||
conda install ros-humble-unilabos-msgs -c uni-lab -c robostack-staging -c conda-forge
|
||||
pip install -e .
|
||||
|
||||
- name: Run check mode (complete_registry)
|
||||
run: |
|
||||
python -m unilabos --check_mode --skip_env_check
|
||||
|
||||
- name: Check for uncommitted changes
|
||||
run: |
|
||||
if ! git diff --exit-code; then
|
||||
echo "::error::检测到文件变化!请先在本地运行 'python -m unilabos --complete_registry' 并提交变更"
|
||||
echo "变化的文件:"
|
||||
git diff --name-only
|
||||
exit 1
|
||||
fi
|
||||
echo "检查通过:无文件变化"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ temp/
|
||||
output/
|
||||
unilabos_data/
|
||||
pyrightconfig.json
|
||||
.cursorignore
|
||||
## Python
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
|
||||
@@ -161,6 +161,12 @@ def parse_args():
|
||||
default=False,
|
||||
help="Complete registry information",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check_mode",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run in check mode for CI: validates registry imports and ensures no file changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no_update_feedback",
|
||||
action="store_true",
|
||||
@@ -314,6 +320,12 @@ def main():
|
||||
BasicConfig.machine_name = machine_name
|
||||
BasicConfig.vis_2d_enable = args_dict["2d_vis"]
|
||||
|
||||
# Check mode 处理
|
||||
check_mode = args_dict.get("check_mode", False)
|
||||
BasicConfig.check_mode = check_mode
|
||||
if check_mode:
|
||||
print_status("Check mode 启用,将进行 complete_registry 检查", "info")
|
||||
|
||||
from unilabos.resources.graphio import (
|
||||
read_node_link_json,
|
||||
read_graphml,
|
||||
@@ -331,10 +343,14 @@ def main():
|
||||
# 显示启动横幅
|
||||
print_unilab_banner(args_dict)
|
||||
|
||||
# 注册表
|
||||
lab_registry = build_registry(
|
||||
args_dict["registry_path"], args_dict.get("complete_registry", False), BasicConfig.upload_registry
|
||||
)
|
||||
# 注册表 - check_mode 时强制启用 complete_registry
|
||||
complete_registry = args_dict.get("complete_registry", False) or check_mode
|
||||
lab_registry = build_registry(args_dict["registry_path"], complete_registry, BasicConfig.upload_registry)
|
||||
|
||||
# Check mode: complete_registry 完成后直接退出,git diff 检测由 CI workflow 执行
|
||||
if check_mode:
|
||||
print_status("Check mode: complete_registry 完成,退出", "info")
|
||||
os._exit(0)
|
||||
|
||||
if BasicConfig.upload_registry:
|
||||
# 设备注册到服务端 - 需要 ak 和 sk
|
||||
|
||||
@@ -58,14 +58,14 @@ class JobResultStore:
|
||||
feedback=feedback or {},
|
||||
timestamp=time.time(),
|
||||
)
|
||||
logger.debug(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
logger.trace(f"[JobResultStore] Stored result for job {job_id[:8]}, status={status}")
|
||||
|
||||
def get_and_remove(self, job_id: str) -> Optional[JobResult]:
|
||||
"""获取并删除任务结果"""
|
||||
with self._results_lock:
|
||||
result = self._results.pop(job_id, None)
|
||||
if result:
|
||||
logger.debug(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
logger.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
|
||||
return result
|
||||
|
||||
def get_result(self, job_id: str) -> Optional[JobResult]:
|
||||
|
||||
@@ -154,7 +154,7 @@ class DeviceActionManager:
|
||||
job_info.set_ready_timeout(10) # 设置10秒超时
|
||||
self.active_jobs[device_key] = job_info
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.info(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Job {job_log} can start immediately for {device_key}")
|
||||
return True
|
||||
|
||||
def start_job(self, job_id: str) -> bool:
|
||||
@@ -210,8 +210,9 @@ class DeviceActionManager:
|
||||
job_info.update_timestamp()
|
||||
# 从all_jobs中移除已结束的job
|
||||
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.info(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||
# job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
# logger.debug(f"[DeviceActionManager] Job {job_log} ended for {device_key}")
|
||||
pass
|
||||
else:
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
logger.warning(f"[DeviceActionManager] Job {job_log} was not active for {device_key}")
|
||||
@@ -227,7 +228,7 @@ class DeviceActionManager:
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start for {device_key}")
|
||||
return next_job
|
||||
|
||||
return None
|
||||
@@ -268,7 +269,7 @@ class DeviceActionManager:
|
||||
# 从all_jobs中移除
|
||||
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.info(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Active job {job_log} cancelled for {device_key}")
|
||||
|
||||
# 启动下一个任务
|
||||
if device_key in self.device_queues and self.device_queues[device_key]:
|
||||
@@ -281,7 +282,7 @@ class DeviceActionManager:
|
||||
next_job_log = format_job_log(
|
||||
next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
logger.trace(f"[DeviceActionManager] Next job {next_job_log} can start after cancel")
|
||||
return True
|
||||
|
||||
# 如果是排队中的任务
|
||||
@@ -295,7 +296,7 @@ class DeviceActionManager:
|
||||
job_log = format_job_log(
|
||||
job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name
|
||||
)
|
||||
logger.info(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||
logger.trace(f"[DeviceActionManager] Queued job {job_log} cancelled for {device_key}")
|
||||
return True
|
||||
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
@@ -565,7 +566,7 @@ class MessageProcessor:
|
||||
|
||||
async def _process_message(self, message_type: str, message_data: Dict[str, Any]):
|
||||
"""处理收到的消息"""
|
||||
logger.debug(f"[MessageProcessor] Processing message: {message_type}")
|
||||
logger.trace(f"[MessageProcessor] Processing message: {message_type}")
|
||||
|
||||
try:
|
||||
if message_type == "pong":
|
||||
@@ -637,13 +638,13 @@ class MessageProcessor:
|
||||
await self._send_action_state_response(
|
||||
device_id, action_name, task_id, job_id, "query_action_status", True, 0
|
||||
)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||
logger.trace(f"[MessageProcessor] Job {job_log} can start immediately")
|
||||
else:
|
||||
# 需要排队
|
||||
await self._send_action_state_response(
|
||||
device_id, action_name, task_id, job_id, "query_action_status", False, 10
|
||||
)
|
||||
logger.info(f"[MessageProcessor] Job {job_log} queued")
|
||||
logger.trace(f"[MessageProcessor] Job {job_log} queued")
|
||||
|
||||
# 通知QueueProcessor有新的队列更新
|
||||
if self.queue_processor:
|
||||
@@ -1128,7 +1129,7 @@ class QueueProcessor:
|
||||
success = self.message_processor.send_message(message)
|
||||
job_log = format_job_log(job_info.job_id, job_info.task_id, job_info.device_id, job_info.action_name)
|
||||
if success:
|
||||
logger.debug(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||
logger.trace(f"[QueueProcessor] Sent busy/need_more for queued job {job_log}")
|
||||
else:
|
||||
logger.warning(f"[QueueProcessor] Failed to send busy status for job {job_log}")
|
||||
|
||||
@@ -1151,7 +1152,7 @@ class QueueProcessor:
|
||||
job_info.action_name,
|
||||
)
|
||||
|
||||
logger.info(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
logger.trace(f"[QueueProcessor] Job {job_log} completed with status: {status}")
|
||||
|
||||
# 结束任务,获取下一个可执行的任务
|
||||
next_job = self.device_manager.end_job(job_id)
|
||||
@@ -1171,8 +1172,8 @@ class QueueProcessor:
|
||||
},
|
||||
}
|
||||
self.message_processor.send_message(message)
|
||||
next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||
logger.info(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||
# next_job_log = format_job_log(next_job.job_id, next_job.task_id, next_job.device_id, next_job.action_name)
|
||||
# logger.debug(f"[QueueProcessor] Notified next job {next_job_log} can start")
|
||||
|
||||
# 立即触发下一轮状态检查
|
||||
self.notify_queue_update()
|
||||
@@ -1314,7 +1315,7 @@ class WebSocketClient(BaseCommunicationClient):
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning(f"[WebSocketClient] Failed to remove job {item.job_id} from HostNode status")
|
||||
|
||||
logger.info(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
# logger.debug(f"[WebSocketClient] Intercepting final status for job_id: {item.job_id} - {status}")
|
||||
|
||||
# 通知队列处理器job完成(包括timeout的job)
|
||||
self.queue_processor.handle_job_completed(item.job_id, status)
|
||||
|
||||
@@ -22,6 +22,7 @@ class BasicConfig:
|
||||
startup_json_path = None # 填写绝对路径
|
||||
disable_browser = False # 禁止浏览器自动打开
|
||||
port = 8002 # 本地HTTP服务
|
||||
check_mode = False # CI 检查模式,用于验证 registry 导入和文件一致性
|
||||
# 'TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'
|
||||
log_level: Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG"
|
||||
|
||||
|
||||
687
unilabos/devices/virtual/workbench.py
Normal file
687
unilabos/devices/virtual/workbench.py
Normal file
@@ -0,0 +1,687 @@
|
||||
"""
|
||||
Virtual Workbench Device - 模拟工作台设备
|
||||
包含:
|
||||
- 1个机械臂 (每次操作3s, 独占锁)
|
||||
- 3个加热台 (每次加热10s, 可并行)
|
||||
|
||||
工作流程:
|
||||
1. A1-A5 物料同时启动,竞争机械臂
|
||||
2. 机械臂将物料移动到空闲加热台
|
||||
3. 加热完成后,机械臂将物料移动到C1-C5
|
||||
|
||||
注意:调用来自线程池,使用 threading.Lock 进行同步
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
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
|
||||
|
||||
|
||||
# ============ TypedDict 返回类型定义 ============
|
||||
|
||||
class MoveToHeatingStationResult(TypedDict):
|
||||
"""move_to_heating_station 返回类型"""
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
|
||||
|
||||
class StartHeatingResult(TypedDict):
|
||||
"""start_heating 返回类型"""
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
material_number: int
|
||||
message: str
|
||||
|
||||
|
||||
class MoveToOutputResult(TypedDict):
|
||||
"""move_to_output 返回类型"""
|
||||
success: bool
|
||||
station_id: int
|
||||
material_id: str
|
||||
|
||||
|
||||
class PrepareMaterialsResult(TypedDict):
|
||||
"""prepare_materials 返回类型 - 批量准备物料"""
|
||||
success: bool
|
||||
count: int
|
||||
material_1: int # 物料编号1
|
||||
material_2: int # 物料编号2
|
||||
material_3: int # 物料编号3
|
||||
material_4: int # 物料编号4
|
||||
material_5: int # 物料编号5
|
||||
message: str
|
||||
|
||||
|
||||
# ============ 状态枚举 ============
|
||||
|
||||
class HeatingStationState(Enum):
|
||||
"""加热台状态枚举"""
|
||||
IDLE = "idle" # 空闲
|
||||
OCCUPIED = "occupied" # 已放置物料,等待加热
|
||||
HEATING = "heating" # 加热中
|
||||
COMPLETED = "completed" # 加热完成,等待取走
|
||||
|
||||
|
||||
class ArmState(Enum):
|
||||
"""机械臂状态枚举"""
|
||||
IDLE = "idle" # 空闲
|
||||
BUSY = "busy" # 工作中
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeatingStation:
|
||||
"""加热台数据结构"""
|
||||
station_id: int
|
||||
state: HeatingStationState = HeatingStationState.IDLE
|
||||
current_material: Optional[str] = None # 当前物料 (如 "A1", "A2")
|
||||
material_number: Optional[int] = None # 物料编号 (1-5)
|
||||
heating_start_time: Optional[float] = None
|
||||
heating_progress: float = 0.0
|
||||
|
||||
|
||||
class VirtualWorkbench:
|
||||
"""
|
||||
Virtual Workbench Device - 虚拟工作台设备
|
||||
|
||||
模拟一个包含1个机械臂和3个加热台的工作站
|
||||
- 机械臂操作耗时3秒,同一时间只能执行一个操作
|
||||
- 加热台加热耗时10秒,3个加热台可并行工作
|
||||
|
||||
工作流:
|
||||
1. 物料A1-A5并发启动(线程池),竞争机械臂使用权
|
||||
2. 获取机械臂后,查找空闲加热台
|
||||
3. 机械臂将物料放入加热台,开始加热
|
||||
4. 加热完成后,机械臂将物料移动到目标位置Cn
|
||||
"""
|
||||
|
||||
_ros_node: BaseROS2DeviceNode
|
||||
|
||||
# 配置常量
|
||||
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):
|
||||
# 处理可能的不同调用方式
|
||||
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 "virtual_workbench"
|
||||
self.config = config or {}
|
||||
|
||||
self.logger = logging.getLogger(f"VirtualWorkbench.{self.device_id}")
|
||||
self.data: Dict[str, Any] = {}
|
||||
|
||||
# 从config中获取可配置参数
|
||||
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()
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task: Optional[str] = None
|
||||
|
||||
# 加热台状态 (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)
|
||||
}
|
||||
self._stations_lock = RLock() # 可重入锁,保护加热台状态
|
||||
|
||||
# 任务追踪
|
||||
self._active_tasks: Dict[str, Dict[str, Any]] = {} # material_id -> task_info
|
||||
self._tasks_lock = Lock()
|
||||
|
||||
# 处理其他kwargs参数
|
||||
skip_keys = {"arm_operation_time", "heating_time", "num_heating_stations"}
|
||||
for key, value in kwargs.items():
|
||||
if key not in skip_keys and not hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
self.logger.info(f"=== 虚拟工作台 {self.device_id} 已创建 ===")
|
||||
self.logger.info(
|
||||
f"机械臂操作时间: {self.ARM_OPERATION_TIME}s | "
|
||||
f"加热时间: {self.HEATING_TIME}s | "
|
||||
f"加热台数量: {self.NUM_HEATING_STATIONS}"
|
||||
)
|
||||
|
||||
@not_action
|
||||
def post_init(self, ros_node: BaseROS2DeviceNode):
|
||||
"""ROS节点初始化后回调"""
|
||||
self._ros_node = ros_node
|
||||
|
||||
@not_action
|
||||
def initialize(self) -> bool:
|
||||
"""初始化虚拟工作台"""
|
||||
self.logger.info(f"初始化虚拟工作台 {self.device_id}")
|
||||
|
||||
# 重置加热台状态 (已在__init__中创建,这里重置为初始状态)
|
||||
with self._stations_lock:
|
||||
for station in self._heating_stations.values():
|
||||
station.state = HeatingStationState.IDLE
|
||||
station.current_material = None
|
||||
station.material_number = None
|
||||
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.logger.info(f"工作台初始化完成: {self.NUM_HEATING_STATIONS}个加热台就绪")
|
||||
return True
|
||||
|
||||
@not_action
|
||||
def cleanup(self) -> bool:
|
||||
"""清理虚拟工作台"""
|
||||
self.logger.info(f"清理虚拟工作台 {self.device_id}")
|
||||
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task = None
|
||||
|
||||
with self._stations_lock:
|
||||
self._heating_stations.clear()
|
||||
|
||||
with self._tasks_lock:
|
||||
self._active_tasks.clear()
|
||||
|
||||
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]]:
|
||||
"""获取所有加热台状态"""
|
||||
with self._stations_lock:
|
||||
return {
|
||||
station_id: {
|
||||
"state": station.state.value,
|
||||
"current_material": station.current_material,
|
||||
"material_number": station.material_number,
|
||||
"heating_progress": station.heating_progress,
|
||||
}
|
||||
for station_id, station in self._heating_stations.items()
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
if message:
|
||||
self.data["message"] = message
|
||||
|
||||
def _find_available_heating_station(self) -> Optional[int]:
|
||||
"""查找空闲的加热台
|
||||
|
||||
Returns:
|
||||
空闲加热台ID,如果没有则返回None
|
||||
"""
|
||||
with self._stations_lock:
|
||||
for station_id, station in self._heating_stations.items():
|
||||
if station.state == HeatingStationState.IDLE:
|
||||
return station_id
|
||||
return None
|
||||
|
||||
def _acquire_arm(self, task_description: str) -> bool:
|
||||
"""获取机械臂使用权(阻塞直到获取)
|
||||
|
||||
Args:
|
||||
task_description: 任务描述,用于日志
|
||||
|
||||
Returns:
|
||||
是否成功获取
|
||||
"""
|
||||
self.logger.info(f"[{task_description}] 等待获取机械臂...")
|
||||
|
||||
# 阻塞等待获取锁
|
||||
self._arm_lock.acquire()
|
||||
|
||||
self._arm_state = ArmState.BUSY
|
||||
self._arm_current_task = task_description
|
||||
self._update_data_status(f"机械臂执行: {task_description}")
|
||||
|
||||
self.logger.info(f"[{task_description}] 成功获取机械臂使用权")
|
||||
return True
|
||||
|
||||
def _release_arm(self):
|
||||
"""释放机械臂"""
|
||||
task = self._arm_current_task
|
||||
self._arm_state = ArmState.IDLE
|
||||
self._arm_current_task = None
|
||||
self._arm_lock.release()
|
||||
self._update_data_status(f"机械臂已释放 (完成: {task})")
|
||||
self.logger.info(f"机械臂已释放 (完成: {task})")
|
||||
|
||||
def prepare_materials(
|
||||
self,
|
||||
count: int = 5,
|
||||
) -> PrepareMaterialsResult:
|
||||
"""
|
||||
批量准备物料 - 虚拟起始节点
|
||||
|
||||
作为工作流的起始节点,生成指定数量的物料编号供后续节点使用。
|
||||
输出5个handle (material_1 ~ material_5),分别对应实验1~5。
|
||||
|
||||
Args:
|
||||
count: 待生成的物料数量,默认5 (生成 A1-A5)
|
||||
|
||||
Returns:
|
||||
PrepareMaterialsResult: 包含 material_1 ~ material_5 用于传递给 move_to_heating_station
|
||||
"""
|
||||
# 生成物料列表 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}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"count": count,
|
||||
"material_1": materials[0] if len(materials) > 0 else 0,
|
||||
"material_2": materials[1] if len(materials) > 1 else 0,
|
||||
"material_3": materials[2] if len(materials) > 2 else 0,
|
||||
"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}",
|
||||
}
|
||||
|
||||
def move_to_heating_station(
|
||||
self,
|
||||
material_number: int,
|
||||
) -> MoveToHeatingStationResult:
|
||||
"""
|
||||
将物料从An位置移动到加热台
|
||||
|
||||
多线程并发调用时,会竞争机械臂使用权,并自动查找空闲加热台
|
||||
|
||||
Args:
|
||||
material_number: 物料编号 (1-5)
|
||||
|
||||
Returns:
|
||||
MoveToHeatingStationResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||
"""
|
||||
# 根据物料编号生成物料ID
|
||||
material_id = f"A{material_number}"
|
||||
task_desc = f"移动{material_id}到加热台"
|
||||
self.logger.info(f"[任务] {task_desc} - 开始执行")
|
||||
|
||||
# 记录任务
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id] = {
|
||||
"status": "waiting_for_arm",
|
||||
"start_time": time.time(),
|
||||
}
|
||||
|
||||
try:
|
||||
# 步骤1: 等待获取机械臂使用权(竞争)
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm"
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
# 步骤2: 查找空闲加热台
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "finding_station"
|
||||
station_id = None
|
||||
|
||||
# 循环等待直到找到空闲加热台
|
||||
while station_id is None:
|
||||
station_id = self._find_available_heating_station()
|
||||
if station_id is None:
|
||||
self.logger.info(f"[{material_id}] 没有空闲加热台,等待中...")
|
||||
# 释放机械臂,等待后重试
|
||||
self._release_arm()
|
||||
time.sleep(0.5)
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
# 步骤3: 占用加热台 - 立即标记为OCCUPIED,防止其他任务选择同一加热台
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.OCCUPIED
|
||||
self._heating_stations[station_id].current_material = material_id
|
||||
self._heating_stations[station_id].material_number = material_number
|
||||
|
||||
# 步骤4: 模拟机械臂移动操作 (3秒)
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving"
|
||||
self._active_tasks[material_id]["assigned_station"] = station_id
|
||||
self.logger.info(f"[{material_id}] 机械臂正在移动到加热台{station_id}...")
|
||||
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
# 步骤5: 放入加热台完成
|
||||
self._update_data_status(f"{material_id}已放入加热台{station_id}")
|
||||
self.logger.info(f"[{material_id}] 已放入加热台{station_id} (用时{self.ARM_OPERATION_TIME}s)")
|
||||
|
||||
# 释放机械臂
|
||||
self._release_arm()
|
||||
|
||||
with self._tasks_lock:
|
||||
self._active_tasks[material_id]["status"] = "placed_on_station"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"station_id": station_id,
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"{material_id}已成功移动到加热台{station_id}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[{material_id}] 移动失败: {str(e)}")
|
||||
if self._arm_lock.locked():
|
||||
self._release_arm()
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": -1,
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
}
|
||||
|
||||
def start_heating(
|
||||
self,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> StartHeatingResult:
|
||||
"""
|
||||
启动指定加热台的加热程序
|
||||
|
||||
Args:
|
||||
station_id: 加热台ID (1-3),从 move_to_heating_station 的 handle 传入
|
||||
material_number: 物料编号,从 move_to_heating_station 的 handle 传入
|
||||
|
||||
Returns:
|
||||
StartHeatingResult: 包含 station_id, material_number 等用于传递给下一个节点
|
||||
"""
|
||||
self.logger.info(f"[加热台{station_id}] 开始加热")
|
||||
|
||||
if station_id not in self._heating_stations:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations[station_id]
|
||||
|
||||
if station.current_material is None:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
}
|
||||
|
||||
if station.state == HeatingStationState.HEATING:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": station.current_material,
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}已经在加热中",
|
||||
}
|
||||
|
||||
material_id = station.current_material
|
||||
|
||||
# 开始加热
|
||||
station.state = HeatingStationState.HEATING
|
||||
station.heating_start_time = time.time()
|
||||
station.heating_progress = 0.0
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "heating"
|
||||
|
||||
self._update_data_status(f"加热台{station_id}开始加热{material_id}")
|
||||
|
||||
# 模拟加热过程 (10秒)
|
||||
start_time = time.time()
|
||||
while True:
|
||||
elapsed = time.time() - start_time
|
||||
progress = min(100.0, (elapsed / self.HEATING_TIME) * 100)
|
||||
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].heating_progress = progress
|
||||
|
||||
self._update_data_status(f"加热台{station_id}加热中: {progress:.1f}%")
|
||||
|
||||
if elapsed >= self.HEATING_TIME:
|
||||
break
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
# 加热完成
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.COMPLETED
|
||||
self._heating_stations[station_id].heating_progress = 100.0
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "heating_completed"
|
||||
|
||||
self._update_data_status(f"加热台{station_id}加热完成")
|
||||
self.logger.info(f"[加热台{station_id}] {material_id}加热完成 (用时{self.HEATING_TIME}s)")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"station_id": station_id,
|
||||
"material_id": material_id,
|
||||
"material_number": material_number,
|
||||
"message": f"加热台{station_id}加热完成",
|
||||
}
|
||||
|
||||
def move_to_output(
|
||||
self,
|
||||
station_id: int,
|
||||
material_number: int,
|
||||
) -> MoveToOutputResult:
|
||||
"""
|
||||
将物料从加热台移动到输出位置Cn
|
||||
|
||||
Args:
|
||||
station_id: 加热台ID (1-3),从 start_heating 的 handle 传入
|
||||
material_number: 物料编号,从 start_heating 的 handle 传入,用于确定输出位置 Cn
|
||||
|
||||
Returns:
|
||||
MoveToOutputResult: 包含执行结果
|
||||
"""
|
||||
output_number = material_number # 物料编号决定输出位置
|
||||
|
||||
if station_id not in self._heating_stations:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"无效的加热台ID: {station_id}",
|
||||
}
|
||||
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations[station_id]
|
||||
material_id = station.current_material
|
||||
|
||||
if material_id is None:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}上没有物料",
|
||||
}
|
||||
|
||||
if station.state != HeatingStationState.COMPLETED:
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": material_id,
|
||||
"output_position": f"C{output_number}",
|
||||
"message": f"加热台{station_id}尚未完成加热 (当前状态: {station.state.value})",
|
||||
}
|
||||
|
||||
output_position = f"C{output_number}"
|
||||
task_desc = f"从加热台{station_id}移动{material_id}到{output_position}"
|
||||
self.logger.info(f"[任务] {task_desc}")
|
||||
|
||||
try:
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "waiting_for_arm_output"
|
||||
|
||||
# 获取机械臂
|
||||
self._acquire_arm(task_desc)
|
||||
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "arm_moving_to_output"
|
||||
|
||||
# 模拟机械臂操作 (3秒)
|
||||
self.logger.info(f"[{material_id}] 机械臂正在从加热台{station_id}取出并移动到{output_position}...")
|
||||
time.sleep(self.ARM_OPERATION_TIME)
|
||||
|
||||
# 清空加热台
|
||||
with self._stations_lock:
|
||||
self._heating_stations[station_id].state = HeatingStationState.IDLE
|
||||
self._heating_stations[station_id].current_material = None
|
||||
self._heating_stations[station_id].material_number = None
|
||||
self._heating_stations[station_id].heating_progress = 0.0
|
||||
self._heating_stations[station_id].heating_start_time = None
|
||||
|
||||
# 释放机械臂
|
||||
self._release_arm()
|
||||
|
||||
# 任务完成
|
||||
with self._tasks_lock:
|
||||
if material_id in self._active_tasks:
|
||||
self._active_tasks[material_id]["status"] = "completed"
|
||||
self._active_tasks[material_id]["end_time"] = time.time()
|
||||
|
||||
self._update_data_status(f"{material_id}已移动到{output_position}")
|
||||
self.logger.info(f"[{material_id}] 已成功移动到{output_position} (用时{self.ARM_OPERATION_TIME}s)")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"station_id": station_id,
|
||||
"material_id": material_id,
|
||||
"output_position": output_position,
|
||||
"message": f"{material_id}已成功移动到{output_position}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"移动到输出位置失败: {str(e)}")
|
||||
if self._arm_lock.locked():
|
||||
self._release_arm()
|
||||
return {
|
||||
"success": False,
|
||||
"station_id": station_id,
|
||||
"material_id": "",
|
||||
"output_position": output_position,
|
||||
"message": f"移动失败: {str(e)}",
|
||||
}
|
||||
|
||||
# ============ 状态属性 ============
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self.data.get("status", "Unknown")
|
||||
|
||||
@property
|
||||
def arm_state(self) -> str:
|
||||
return self._arm_state.value
|
||||
|
||||
@property
|
||||
def arm_current_task(self) -> str:
|
||||
return self._arm_current_task or ""
|
||||
|
||||
@property
|
||||
def heating_station_1_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
def heating_station_1_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
def heating_station_1_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(1)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
def heating_station_2_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
def heating_station_2_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
def heating_station_2_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(2)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
def heating_station_3_state(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.state.value if station else "unknown"
|
||||
|
||||
@property
|
||||
def heating_station_3_material(self) -> str:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.current_material or "" if station else ""
|
||||
|
||||
@property
|
||||
def heating_station_3_progress(self) -> float:
|
||||
with self._stations_lock:
|
||||
station = self._heating_stations.get(3)
|
||||
return station.heating_progress if station else 0.0
|
||||
|
||||
@property
|
||||
def active_tasks_count(self) -> int:
|
||||
with self._tasks_lock:
|
||||
return len(self._active_tasks)
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
return self.data.get("message", "")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,20 @@ class Registry:
|
||||
|
||||
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
|
||||
|
||||
# 获取 HostNode 类的增强信息,用于自动生成 action schema
|
||||
host_node_enhanced_info = get_enhanced_class_info(
|
||||
"unilabos.ros.nodes.presets.host_node:HostNode", use_dynamic=True
|
||||
)
|
||||
|
||||
# 为 test_latency 生成 schema,保留原有 description
|
||||
test_latency_method_info = host_node_enhanced_info.get("action_methods", {}).get("test_latency", {})
|
||||
test_latency_schema = self._generate_unilab_json_command_schema(
|
||||
test_latency_method_info.get("args", []),
|
||||
"test_latency",
|
||||
test_latency_method_info.get("return_annotation"),
|
||||
)
|
||||
test_latency_schema["description"] = "用于测试延迟的动作,返回延迟时间和时间差。"
|
||||
|
||||
self.device_type_registry.update(
|
||||
{
|
||||
"host_node": {
|
||||
@@ -152,14 +166,19 @@ class Registry:
|
||||
},
|
||||
},
|
||||
"test_latency": {
|
||||
"type": self.EmptyIn,
|
||||
"type": (
|
||||
"UniLabJsonCommandAsync"
|
||||
if test_latency_method_info.get("is_async", False)
|
||||
else "UniLabJsonCommand"
|
||||
),
|
||||
"goal": {},
|
||||
"feedback": {},
|
||||
"result": {},
|
||||
"schema": ros_action_to_json_schema(
|
||||
self.EmptyIn, "用于测试延迟的动作,返回延迟时间和时间差。"
|
||||
),
|
||||
"goal_default": {},
|
||||
"schema": test_latency_schema,
|
||||
"goal_default": {
|
||||
arg["name"]: arg["default"]
|
||||
for arg in test_latency_method_info.get("args", [])
|
||||
},
|
||||
"handles": {},
|
||||
},
|
||||
"auto-test_resource": {
|
||||
@@ -540,11 +559,9 @@ class Registry:
|
||||
|
||||
return final_schema
|
||||
|
||||
def _preserve_field_descriptions(
|
||||
self, new_schema: Dict[str, Any], previous_schema: Dict[str, Any]
|
||||
) -> None:
|
||||
def _preserve_field_descriptions(self, new_schema: Dict[str, Any], previous_schema: Dict[str, Any]) -> None:
|
||||
"""
|
||||
保留之前 schema 中 goal/feedback/result 下一级字段的 description
|
||||
保留之前 schema 中 goal/feedback/result 下一级字段的 description 和 title
|
||||
|
||||
Args:
|
||||
new_schema: 新生成的 schema(会被修改)
|
||||
@@ -566,6 +583,9 @@ class Registry:
|
||||
# 保留字段的 description
|
||||
if "description" in prev_field and prev_field["description"]:
|
||||
field_schema["description"] = prev_field["description"]
|
||||
# 保留字段的 title(用户自定义的中文名)
|
||||
if "title" in prev_field and prev_field["title"]:
|
||||
field_schema["title"] = prev_field["title"]
|
||||
|
||||
def _is_typed_dict(self, annotation: Any) -> bool:
|
||||
"""
|
||||
|
||||
@@ -770,13 +770,16 @@ def ros_message_to_json_schema(msg_class: Any, field_name: str) -> Dict[str, Any
|
||||
return schema
|
||||
|
||||
|
||||
def ros_action_to_json_schema(action_class: Any, description="") -> Dict[str, Any]:
|
||||
def ros_action_to_json_schema(
|
||||
action_class: Any, description="", previous_schema: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
将 ROS Action 类转换为 JSON Schema
|
||||
|
||||
Args:
|
||||
action_class: ROS Action 类
|
||||
description: 描述
|
||||
previous_schema: 之前的 schema,用于保留 goal/feedback/result 下一级字段的 description
|
||||
|
||||
Returns:
|
||||
完整的 JSON Schema 定义
|
||||
@@ -810,9 +813,44 @@ def ros_action_to_json_schema(action_class: Any, description="") -> Dict[str, An
|
||||
"required": ["goal"],
|
||||
}
|
||||
|
||||
# 保留之前 schema 中 goal/feedback/result 下一级字段的 description
|
||||
if previous_schema:
|
||||
_preserve_field_descriptions(schema, previous_schema)
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def _preserve_field_descriptions(
|
||||
new_schema: Dict[str, Any], previous_schema: Dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
保留之前 schema 中 goal/feedback/result 下一级字段的 description 和 title
|
||||
|
||||
Args:
|
||||
new_schema: 新生成的 schema(会被修改)
|
||||
previous_schema: 之前的 schema
|
||||
"""
|
||||
for section in ["goal", "feedback", "result"]:
|
||||
new_section = new_schema.get("properties", {}).get(section, {})
|
||||
prev_section = previous_schema.get("properties", {}).get(section, {})
|
||||
|
||||
if not new_section or not prev_section:
|
||||
continue
|
||||
|
||||
new_props = new_section.get("properties", {})
|
||||
prev_props = prev_section.get("properties", {})
|
||||
|
||||
for field_name, field_schema in new_props.items():
|
||||
if field_name in prev_props:
|
||||
prev_field = prev_props[field_name]
|
||||
# 保留字段的 description
|
||||
if "description" in prev_field and prev_field["description"]:
|
||||
field_schema["description"] = prev_field["description"]
|
||||
# 保留字段的 title(用户自定义的中文名)
|
||||
if "title" in prev_field and prev_field["title"]:
|
||||
field_schema["title"] = prev_field["title"]
|
||||
|
||||
|
||||
def convert_ros_action_to_jsonschema(
|
||||
action_name_or_type: Union[str, Type], output_file: Optional[str] = None, format: str = "json"
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
@@ -5,7 +5,8 @@ import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any, List, ClassVar, Set, TypedDict, Union
|
||||
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
|
||||
@@ -62,6 +63,18 @@ class TestResourceReturn(TypedDict):
|
||||
devices: List[DeviceSlot]
|
||||
|
||||
|
||||
class TestLatencyReturn(TypedDict):
|
||||
"""test_latency方法的返回值类型"""
|
||||
|
||||
avg_rtt_ms: float
|
||||
avg_time_diff_ms: float
|
||||
max_time_error_ms: float
|
||||
task_delay_ms: float
|
||||
raw_delay_ms: float
|
||||
test_count: int
|
||||
status: str
|
||||
|
||||
|
||||
class HostNode(BaseROS2DeviceNode):
|
||||
"""
|
||||
主机节点类,负责管理设备、资源和控制器
|
||||
@@ -853,8 +866,13 @@ class HostNode(BaseROS2DeviceNode):
|
||||
# 适配后端的一些额外处理
|
||||
return_value = return_info.get("return_value")
|
||||
if isinstance(return_value, dict):
|
||||
unilabos_samples = return_info.get("unilabos_samples")
|
||||
if isinstance(unilabos_samples, list):
|
||||
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["unilabos_samples"] = unilabos_samples
|
||||
suc = return_info.get("suc", False)
|
||||
if not suc:
|
||||
@@ -881,7 +899,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
# 清理 _goals 中的记录
|
||||
if job_id in self._goals:
|
||||
del self._goals[job_id]
|
||||
self.lab_logger().debug(f"[Host Node] Removed goal {job_id[:8]} from _goals")
|
||||
self.lab_logger().trace(f"[Host Node] Removed goal {job_id[:8]} from _goals")
|
||||
|
||||
# 存储结果供 HTTP API 查询
|
||||
try:
|
||||
@@ -1326,10 +1344,20 @@ class HostNode(BaseROS2DeviceNode):
|
||||
self.lab_logger().debug(f"[Host Node-Resource] List parameters: {request}")
|
||||
return response
|
||||
|
||||
def test_latency(self):
|
||||
def test_latency(self) -> TestLatencyReturn:
|
||||
"""
|
||||
测试网络延迟的action实现
|
||||
通过5次ping-pong机制校对时间误差并计算实际延迟
|
||||
|
||||
Returns:
|
||||
TestLatencyReturn: 包含延迟测试结果的字典,包括:
|
||||
- avg_rtt_ms: 平均往返时间(毫秒)
|
||||
- avg_time_diff_ms: 平均时间差(毫秒)
|
||||
- max_time_error_ms: 最大时间误差(毫秒)
|
||||
- task_delay_ms: 实际任务延迟(毫秒),-1表示无法计算
|
||||
- raw_delay_ms: 原始时间差(毫秒),-1表示无法计算
|
||||
- test_count: 有效测试次数
|
||||
- status: 测试状态,"success"表示成功,"all_timeout"表示全部超时
|
||||
"""
|
||||
import uuid as uuid_module
|
||||
|
||||
@@ -1392,7 +1420,15 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
if not ping_results:
|
||||
self.lab_logger().error("❌ 所有ping-pong测试都失败了")
|
||||
return {"status": "all_timeout"}
|
||||
return {
|
||||
"avg_rtt_ms": -1.0,
|
||||
"avg_time_diff_ms": -1.0,
|
||||
"max_time_error_ms": -1.0,
|
||||
"task_delay_ms": -1.0,
|
||||
"raw_delay_ms": -1.0,
|
||||
"test_count": 0,
|
||||
"status": "all_timeout",
|
||||
}
|
||||
|
||||
# 统计分析
|
||||
rtts = [r["rtt_ms"] for r in ping_results]
|
||||
@@ -1400,7 +1436,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
avg_rtt_ms = sum(rtts) / len(rtts)
|
||||
avg_time_diff_ms = sum(time_diffs) / len(time_diffs)
|
||||
max_time_diff_error_ms = max(abs(min(time_diffs)), abs(max(time_diffs)))
|
||||
max_time_diff_error_ms: float = max(abs(min(time_diffs)), abs(max(time_diffs)))
|
||||
|
||||
self.lab_logger().info("-" * 50)
|
||||
self.lab_logger().info("[测试统计]")
|
||||
@@ -1440,7 +1476,7 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
self.lab_logger().info("=" * 60)
|
||||
|
||||
return {
|
||||
res: TestLatencyReturn = {
|
||||
"avg_rtt_ms": avg_rtt_ms,
|
||||
"avg_time_diff_ms": avg_time_diff_ms,
|
||||
"max_time_error_ms": max_time_diff_error_ms,
|
||||
@@ -1451,9 +1487,14 @@ class HostNode(BaseROS2DeviceNode):
|
||||
"test_count": len(ping_results),
|
||||
"status": "success",
|
||||
}
|
||||
return res
|
||||
|
||||
def test_resource(
|
||||
self, resource: ResourceSlot = None, resources: List[ResourceSlot] = None, device: DeviceSlot = None, devices: List[DeviceSlot] = None
|
||||
self,
|
||||
resource: ResourceSlot = None,
|
||||
resources: List[ResourceSlot] = None,
|
||||
device: DeviceSlot = None,
|
||||
devices: List[DeviceSlot] = None,
|
||||
) -> TestResourceReturn:
|
||||
if resources is None:
|
||||
resources = []
|
||||
@@ -1514,7 +1555,9 @@ class HostNode(BaseROS2DeviceNode):
|
||||
|
||||
# 构建服务地址
|
||||
srv_address = f"/srv{namespace}/s2c_resource_tree"
|
||||
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------")
|
||||
self.lab_logger().trace(
|
||||
f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation started -------"
|
||||
)
|
||||
|
||||
# 创建服务客户端
|
||||
sclient = self.create_client(SerialCommand, srv_address)
|
||||
@@ -1549,7 +1592,9 @@ class HostNode(BaseROS2DeviceNode):
|
||||
time.sleep(0.05)
|
||||
|
||||
response = future.result()
|
||||
self.lab_logger().trace(f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------")
|
||||
self.lab_logger().trace(
|
||||
f"[Host Node-Resource] Host -> {device_id} ResourceTree {action} operation completed -------"
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
28
unilabos/test/experiments/virtual_bench.json
Normal file
28
unilabos/test/experiments/virtual_bench.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "workbench_1",
|
||||
"name": "虚拟工作台",
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"type": "device",
|
||||
"class": "virtual_workbench",
|
||||
"position": {
|
||||
"x": 400,
|
||||
"y": 300,
|
||||
"z": 0
|
||||
},
|
||||
"config": {
|
||||
"arm_operation_time": 3.0,
|
||||
"heating_time": 10.0,
|
||||
"num_heating_stations": 3
|
||||
},
|
||||
"data": {
|
||||
"status": "Ready",
|
||||
"arm_state": "idle",
|
||||
"message": "工作台就绪"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -182,3 +182,49 @@ def get_all_subscriptions(instance) -> list:
|
||||
except Exception:
|
||||
pass
|
||||
return subscriptions
|
||||
|
||||
|
||||
def not_action(func: F) -> F:
|
||||
"""
|
||||
标记方法为非动作的装饰器
|
||||
|
||||
用于装饰 driver 类中的方法,使其在 complete_registry 时不被识别为动作。
|
||||
适用于辅助方法、内部工具方法等不应暴露为设备动作的公共方法。
|
||||
|
||||
Example:
|
||||
class MyDriver:
|
||||
@not_action
|
||||
def helper_method(self):
|
||||
# 这个方法不会被注册为动作
|
||||
pass
|
||||
|
||||
def actual_action(self, param: str):
|
||||
# 这个方法会被注册为动作
|
||||
self.helper_method()
|
||||
|
||||
Note:
|
||||
- 可以与其他装饰器组合使用,@not_action 应放在最外层
|
||||
- 仅影响 complete_registry 的动作识别,不影响方法的正常调用
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# 在函数上附加标记
|
||||
wrapper._is_not_action = True # type: ignore[attr-defined]
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_not_action(func) -> bool:
|
||||
"""
|
||||
检查函数是否被标记为非动作
|
||||
|
||||
Args:
|
||||
func: 被检查的函数
|
||||
|
||||
Returns:
|
||||
如果函数被 @not_action 装饰则返回 True,否则返回 False
|
||||
"""
|
||||
return getattr(func, "_is_not_action", False)
|
||||
|
||||
@@ -28,6 +28,7 @@ __all__ = [
|
||||
from ast import Constant
|
||||
|
||||
from unilabos.utils import logger
|
||||
from unilabos.utils.decorator import is_not_action
|
||||
|
||||
|
||||
class ImportManager:
|
||||
@@ -275,6 +276,9 @@ class ImportManager:
|
||||
method_info = self._analyze_method_signature(method)
|
||||
result["status_methods"][actual_name] = method_info
|
||||
elif not name.startswith("_"):
|
||||
# 检查是否被 @not_action 装饰器标记
|
||||
if is_not_action(method):
|
||||
continue
|
||||
# 其他非_开头的方法归类为action
|
||||
method_info = self._analyze_method_signature(method)
|
||||
result["action_methods"][name] = method_info
|
||||
@@ -330,6 +334,9 @@ class ImportManager:
|
||||
if actual_name not in result["status_methods"]:
|
||||
result["status_methods"][actual_name] = method_info
|
||||
else:
|
||||
# 检查是否被 @not_action 装饰器标记
|
||||
if self._is_not_action_method(node):
|
||||
continue
|
||||
# 其他非_开头的方法归类为action
|
||||
result["action_methods"][method_name] = method_info
|
||||
return result
|
||||
@@ -450,6 +457,13 @@ class ImportManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_not_action_method(self, node: ast.FunctionDef) -> bool:
|
||||
"""检查是否是@not_action装饰的方法"""
|
||||
for decorator in node.decorator_list:
|
||||
if isinstance(decorator, ast.Name) and decorator.id == "not_action":
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_property_name_from_setter(self, node: ast.FunctionDef) -> str:
|
||||
"""从setter装饰器中获取属性名"""
|
||||
for decorator in node.decorator_list:
|
||||
|
||||
Reference in New Issue
Block a user