Files
Uni-Lab-OS/unilabos/app/web/controller.py
Xuwznln 2a5ddd611d Upgrade to py 3.11.14; ROS2 Humble 0.7; unilabos 0.10.16
Workbench example, adjust log level, and ci check (#220)

* TestLatency Return Value Example & gitignore update

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

* Add CI Check

Fix/workstation yb revision (#217)

* Revert log change & update registry

* Revert opcua client & move electrolyte node

Workstation yb merge dev ready 260113 (#216)

* feat(bioyond): 添加计算实验设计功能,支持化合物配比和滴定比例参数

* feat(bioyond): 添加测量小瓶功能,支持基本参数配置

* feat(bioyond): 添加测量小瓶配置,支持新设备参数

* feat(bioyond): 更新仓库布局和尺寸,支持竖向排列的测量小瓶和试剂存放堆栈

* feat(bioyond): 优化任务创建流程,确保无论成功与否都清理任务队列以避免重复累积

* feat(bioyond): 添加设置反应器温度功能,支持温度范围和异常处理

* feat(bioyond): 调整反应器位置配置,统一坐标格式

* feat(bioyond): 添加调度器启动功能,支持任务队列执行并处理异常

* feat(bioyond): 优化调度器启动功能,添加异常处理并更新相关配置

* feat(opcua): 增强节点ID解析兼容性和数据类型处理

改进节点ID解析逻辑以支持多种格式,包括字符串和数字标识符
添加数据类型转换处理,确保写入值时类型匹配
优化错误提示信息,便于调试节点连接问题

* feat(registry): 新增后处理站的设备配置文件

添加后处理站的YAML配置文件,包含动作映射、状态类型和设备描述

* 添加调度器启动功能,合并物料参数配置,优化物料参数处理逻辑

* 添加从 Bioyond 系统自动同步工作流序列的功能,并更新相关配置

* fix:兼容 BioyondReactionStation 中 workflow_sequence 被重写为 property

* fix:同步工作流序列

* feat: remove commented workflow synchronization from `reaction_station.py`.

* 添加时间约束功能及相关配置

* fix:自动更新物料缓存功能,添加物料时更新缓存并在删除时移除缓存项

* fix:在添加物料时处理字符串和字典返回值,确保正确更新缓存

* fix:更新奔曜错误处理报送为物料变更报送,调整日志记录和响应消息

* feat:添加实验报告简化功能,去除冗余信息并保留关键信息

* feat: 添加任务状态事件发布功能,监控并报告任务运行、超时、完成和错误状态

* fix: 修复添加物料时数据格式错误

* Refactor bioyond_dispensing_station and reaction_station_bioyond YAML configurations

- Removed redundant action value mappings from bioyond_dispensing_station.
- Updated goal properties in bioyond_dispensing_station to use enums for target_stack and other parameters.
- Changed data types for end_point and start_point in reaction_station_bioyond to use string enums (Start, End).
- Simplified descriptions and updated measurement units from μL to mL where applicable.
- Removed unused commands from reaction_station_bioyond to streamline the configuration.

* fix:Change the material unit from μL to mL

* fix:refresh_material_cache

* feat: 动态获取工作流步骤ID,优化工作流配置

* feat: 添加清空服务端所有非核心工作流功能

* fix:修复Bottle类的序列化和反序列化方法

* feat:增强材料缓存更新逻辑,支持处理返回数据中的详细信息

* Add debug log

* feat(workstation): update bioyond config migration and coin cell material search logic

- Migrate bioyond_cell config to JSON structure and remove global variable dependencies
- Implement material search confirmation dialog auto-handling
- Add documentation: 20260113_物料搜寻确认弹窗自动处理功能.md and 20260113_配置迁移修改总结.md

* Refactor module paths for Bioyond devices in YAML configuration files

- Updated the module path for BioyondDispensingStation in bioyond_dispensing_station.yaml to reflect the new directory structure.
- Updated the module path for BioyondReactionStation and BioyondReactor in reaction_station_bioyond.yaml to align with the revised organization of the codebase.

* fix: WareHouse 的不可哈希类型错误,优化父节点去重逻辑

* refactor: Move config from module to instance initialization

* fix: 修正 reaction_station 目录名拼写错误

* feat: Integrate material search logic and cleanup deprecated files

- Update coin_cell_assembly.py with material search dialog handling
- Update YB_warehouses.py with latest warehouse configurations
- Remove outdated documentation and test data files

* Refactor: Use instance attributes for action names and workflow step IDs

* refactor: Split tipbox storage into left and right warehouses

* refactor: Merge tipbox storage left and right into single warehouse

---------

Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com>
Co-authored-by: Andy6M <xieqiming1132@qq.com>

fix: WareHouse 的不可哈希类型错误,优化父节点去重逻辑

fix parent_uuid fetch when bind_parent_id == node_name

物料更新也是用父节点进行报送

Add None conversion for tube rack etc.

Add set_liquid example.

Add create_resource and test_resource example.

Add restart.
Temp allow action message.

Add no_update_feedback option.

Create session_id by edge.

bump version to 0.10.15

temp cancel update req
2026-01-27 15:21:55 +08:00

588 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Web API Controller
提供Web API的控制器函数处理设备、任务和动作相关的业务逻辑
"""
import threading
import time
import traceback
import uuid
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, Tuple
from unilabos.app.model import JobAddReq, JobData
from unilabos.ros.nodes.presets.host_node import HostNode
from unilabos.utils import logger
@dataclass
class JobResult:
"""任务结果数据"""
job_id: str
status: int # 4:SUCCEEDED, 5:CANCELED, 6:ABORTED
result: Dict[str, Any] = field(default_factory=dict)
feedback: Dict[str, Any] = field(default_factory=dict)
timestamp: float = field(default_factory=time.time)
class JobResultStore:
"""任务结果存储(单例)"""
_instance: Optional["JobResultStore"] = None
_lock = threading.Lock()
def __init__(self):
if not hasattr(self, "_initialized"):
self._results: Dict[str, JobResult] = {}
self._results_lock = threading.RLock()
self._initialized = True
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def store_result(
self, job_id: str, status: int, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
):
"""存储任务结果"""
with self._results_lock:
self._results[job_id] = JobResult(
job_id=job_id,
status=status,
result=result or {},
feedback=feedback or {},
timestamp=time.time(),
)
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.trace(f"[JobResultStore] Retrieved and removed result for job {job_id[:8]}")
return result
def get_result(self, job_id: str) -> Optional[JobResult]:
"""仅获取任务结果(不删除)"""
with self._results_lock:
return self._results.get(job_id)
def cleanup_old_results(self, max_age_seconds: float = 3600):
"""清理过期的结果"""
current_time = time.time()
with self._results_lock:
expired_jobs = [
job_id for job_id, result in self._results.items() if current_time - result.timestamp > max_age_seconds
]
for job_id in expired_jobs:
del self._results[job_id]
logger.debug(f"[JobResultStore] Cleaned up expired result for job {job_id[:8]}")
# 全局结果存储实例
job_result_store = JobResultStore()
def store_job_result(
job_id: str, status: str, result: Optional[Dict[str, Any]], feedback: Optional[Dict[str, Any]] = None
):
"""存储任务结果(供外部调用)
Args:
job_id: 任务ID
status: 状态字符串 ("success", "failed", "cancelled")
result: 结果数据
feedback: 反馈数据
"""
# 转换状态字符串为整数
status_map = {
"success": 4, # SUCCEEDED
"failed": 6, # ABORTED
"cancelled": 5, # CANCELED
"running": 2, # EXECUTING
}
status_int = status_map.get(status, 0)
# 只存储最终状态
if status_int in (4, 5, 6):
job_result_store.store_result(job_id, status_int, result, feedback)
def get_resources() -> Tuple[bool, Any]:
"""获取资源配置
Returns:
Tuple[bool, Any]: (是否成功, 资源配置或错误信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, "Host node not initialized"
return True, host_node.resources_config
def devices() -> Tuple[bool, Any]:
"""获取设备配置
Returns:
Tuple[bool, Any]: (是否成功, 设备配置或错误信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, "Host node not initialized"
return True, host_node.devices_config
def job_info(job_id: str, remove_after_read: bool = True) -> JobData:
"""获取任务信息
Args:
job_id: 任务ID
remove_after_read: 是否在读取后删除结果默认True
Returns:
JobData: 任务数据
"""
# 首先检查结果存储中是否有已完成的结果
if remove_after_read:
stored_result = job_result_store.get_and_remove(job_id)
else:
stored_result = job_result_store.get_result(job_id)
if stored_result:
# 有存储的结果,直接返回
return JobData(
jobId=job_id,
status=stored_result.status,
result=stored_result.result,
)
# 没有存储的结果,从 HostNode 获取当前状态
host_node = HostNode.get_instance(0)
if host_node is None:
return JobData(jobId=job_id, status=0)
get_goal_status = host_node.get_goal_status(job_id)
return JobData(jobId=job_id, status=get_goal_status)
def check_device_action_busy(device_id: str, action_name: str) -> Tuple[bool, Optional[str]]:
"""检查设备动作是否正在执行(被占用)
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
Tuple[bool, Optional[str]]: (是否繁忙, 当前执行的job_id或None)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, None
device_action_key = f"/devices/{device_id}/{action_name}"
# 检查 _device_action_status 中是否有正在执行的任务
if device_action_key in host_node._device_action_status:
status = host_node._device_action_status[device_action_key]
if status.job_ids:
# 返回第一个正在执行的job_id
current_job_id = next(iter(status.job_ids.keys()), None)
return True, current_job_id
return False, None
def _get_action_type(device_id: str, action_name: str) -> Optional[str]:
"""从注册表自动获取动作类型
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
动作类型字符串未找到返回None
"""
try:
from unilabos.ros.nodes.base_device_node import registered_devices
# 方法1: 从运行时注册设备获取
if device_id in registered_devices:
device_info = registered_devices[device_id]
base_node = device_info.get("base_node_instance")
if base_node and hasattr(base_node, "_action_value_mappings"):
action_mappings = base_node._action_value_mappings
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
action_type = action_mappings[key].get("type")
if action_type:
# 转换为字符串格式
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
return f"{action_type.__module__}.{action_type.__name__}"
return str(action_type)
# 方法2: 从lab_registry获取
from unilabos.registry.registry import lab_registry
host_node = HostNode.get_instance(0)
if host_node and lab_registry:
devices_config = host_node.devices_config
device_class = None
for tree in devices_config.trees:
node = tree.root_node
if node.res_content.id == device_id:
device_class = node.res_content.klass
break
if device_class and device_class in lab_registry.device_type_registry:
device_type_info = lab_registry.device_type_registry[device_class]
class_info = device_type_info.get("class", {})
action_mappings = class_info.get("action_value_mappings", {})
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
action_type = action_mappings[key].get("type")
if action_type:
if hasattr(action_type, "__module__") and hasattr(action_type, "__name__"):
return f"{action_type.__module__}.{action_type.__name__}"
return str(action_type)
except Exception as e:
logger.warning(f"[Controller] Failed to get action type for {device_id}/{action_name}: {str(e)}")
return None
def job_add(req: JobAddReq) -> JobData:
"""添加任务(检查设备是否繁忙,繁忙则返回失败)
Args:
req: 任务添加请求
Returns:
JobData: 任务数据(包含状态)
"""
# 服务端自动生成 job_id 和 task_id
job_id = str(uuid.uuid4())
task_id = str(uuid.uuid4())
# 服务端自动生成 server_info
server_info = {"send_timestamp": time.time()}
host_node = HostNode.get_instance(0)
if host_node is None:
logger.error(f"[Controller] Host node not initialized for job: {job_id[:8]}")
return JobData(jobId=job_id, status=6) # 6 = ABORTED
# 解析动作信息
action_name = req.data.get("action", req.action) if req.data else req.action
action_args = req.data.get("action_kwargs") or req.data.get("action_args") if req.data else req.action_args
if action_args is None:
action_args = req.action_args or {}
elif isinstance(action_args, dict) and "command" in action_args:
action_args = action_args["command"]
# 自动获取 action_type
action_type = _get_action_type(req.device_id, action_name)
if action_type is None:
logger.error(f"[Controller] Action type not found for {req.device_id}/{action_name}")
return JobData(jobId=job_id, status=6) # ABORTED
# 检查设备动作是否繁忙
is_busy, current_job_id = check_device_action_busy(req.device_id, action_name)
if is_busy:
logger.warning(
f"[Controller] Device action busy: {req.device_id}/{action_name}, "
f"current job: {current_job_id[:8] if current_job_id else 'unknown'}"
)
# 返回失败状态status=6 表示 ABORTED
return JobData(jobId=job_id, status=6)
# 设备空闲,提交任务执行
try:
from unilabos.app.ws_client import QueueItem
device_action_key = f"/devices/{req.device_id}/{action_name}"
queue_item = QueueItem(
task_type="job_call_back_status",
device_id=req.device_id,
action_name=action_name,
task_id=task_id,
job_id=job_id,
device_action_key=device_action_key,
)
host_node.send_goal(
queue_item,
action_type=action_type,
action_kwargs=action_args,
server_info=server_info,
)
logger.info(f"[Controller] Job submitted: {job_id[:8]} -> {req.device_id}/{action_name}")
# 返回已接受状态status=1 表示 ACCEPTED
return JobData(jobId=job_id, status=1)
except ValueError as e:
# ActionClient not found 等错误
logger.error(f"[Controller] Action not available: {str(e)}")
return JobData(jobId=job_id, status=6) # ABORTED
except Exception as e:
logger.error(f"[Controller] Error submitting job: {str(e)}")
traceback.print_exc()
return JobData(jobId=job_id, status=6) # ABORTED
def get_online_devices() -> Tuple[bool, Dict[str, Any]]:
"""获取在线设备列表
Returns:
Tuple[bool, Dict]: (是否成功, 在线设备信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
online_devices = {}
for device_key in host_node._online_devices:
# device_key 格式: "namespace/device_id"
parts = device_key.split("/")
if len(parts) >= 2:
device_id = parts[-1]
else:
device_id = device_key
# 获取设备详细信息
device_info = registered_devices.get(device_id, {})
machine_name = host_node.device_machine_names.get(device_id, "未知")
online_devices[device_id] = {
"device_key": device_key,
"namespace": host_node.devices_names.get(device_id, ""),
"machine_name": machine_name,
"uuid": device_info.get("uuid", "") if device_info else "",
"node_name": device_info.get("node_name", "") if device_info else "",
}
return True, {
"online_devices": online_devices,
"total_count": len(online_devices),
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"[Controller] Error getting online devices: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_device_actions(device_id: str) -> Tuple[bool, Dict[str, Any]]:
"""获取设备可用的动作列表
Args:
device_id: 设备ID
Returns:
Tuple[bool, Dict]: (是否成功, 动作列表信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
from unilabos.app.web.utils.action_utils import get_action_info
# 检查设备是否已注册
if device_id not in registered_devices:
return False, {"error": f"Device not found: {device_id}"}
device_info = registered_devices[device_id]
actions = device_info.get("actions", {})
actions_list = {}
for action_name, action_server in actions.items():
try:
action_info = get_action_info(action_server, action_name)
# 检查动作是否繁忙
is_busy, current_job = check_device_action_busy(device_id, action_name)
actions_list[action_name] = {
**action_info,
"is_busy": is_busy,
"current_job_id": current_job[:8] if current_job else None,
}
except Exception as e:
logger.warning(f"[Controller] Error getting action info for {action_name}: {str(e)}")
actions_list[action_name] = {
"type_name": "unknown",
"action_path": f"/devices/{device_id}/{action_name}",
"is_busy": False,
"error": str(e),
}
return True, {
"device_id": device_id,
"actions": actions_list,
"action_count": len(actions_list),
}
except Exception as e:
logger.error(f"[Controller] Error getting device actions: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_action_schema(device_id: str, action_name: str) -> Tuple[bool, Dict[str, Any]]:
"""获取动作的Schema详情
Args:
device_id: 设备ID
action_name: 动作名称
Returns:
Tuple[bool, Dict]: (是否成功, Schema信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.registry.registry import lab_registry
from unilabos.ros.nodes.base_device_node import registered_devices
result = {
"device_id": device_id,
"action_name": action_name,
"schema": None,
"goal_default": None,
"action_type": None,
"is_busy": False,
}
# 检查动作是否繁忙
is_busy, current_job = check_device_action_busy(device_id, action_name)
result["is_busy"] = is_busy
result["current_job_id"] = current_job[:8] if current_job else None
# 方法1: 从 registered_devices 获取运行时信息
if device_id in registered_devices:
device_info = registered_devices[device_id]
base_node = device_info.get("base_node_instance")
if base_node and hasattr(base_node, "_action_value_mappings"):
action_mappings = base_node._action_value_mappings
if action_name in action_mappings:
mapping = action_mappings[action_name]
result["schema"] = mapping.get("schema")
result["goal_default"] = mapping.get("goal_default")
result["action_type"] = str(mapping.get("type", ""))
# 方法2: 从 lab_registry 获取注册表信息(如果运行时没有)
if result["schema"] is None and lab_registry:
# 尝试查找设备类型
devices_config = host_node.devices_config
device_class = None
# 从配置中获取设备类型
for tree in devices_config.trees:
node = tree.root_node
if node.res_content.id == device_id:
device_class = node.res_content.klass
break
if device_class and device_class in lab_registry.device_type_registry:
device_type_info = lab_registry.device_type_registry[device_class]
class_info = device_type_info.get("class", {})
action_mappings = class_info.get("action_value_mappings", {})
# 尝试直接匹配或 auto- 前缀匹配
for key in [action_name, f"auto-{action_name}"]:
if key in action_mappings:
mapping = action_mappings[key]
result["schema"] = mapping.get("schema")
result["goal_default"] = mapping.get("goal_default")
result["action_type"] = str(mapping.get("type", ""))
result["handles"] = mapping.get("handles", {})
result["placeholder_keys"] = mapping.get("placeholder_keys", {})
break
if result["schema"] is None:
return False, {"error": f"Action schema not found: {device_id}/{action_name}"}
return True, result
except Exception as e:
logger.error(f"[Controller] Error getting action schema: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}
def get_all_available_actions() -> Tuple[bool, Dict[str, Any]]:
"""获取所有设备的可用动作
Returns:
Tuple[bool, Dict]: (是否成功, 所有设备的动作信息)
"""
host_node = HostNode.get_instance(0)
if host_node is None:
return False, {"error": "Host node not initialized"}
try:
from unilabos.ros.nodes.base_device_node import registered_devices
from unilabos.app.web.utils.action_utils import get_action_info
all_actions = {}
total_action_count = 0
for device_id, device_info in registered_devices.items():
actions = device_info.get("actions", {})
device_actions = {}
for action_name, action_server in actions.items():
try:
action_info = get_action_info(action_server, action_name)
is_busy, current_job = check_device_action_busy(device_id, action_name)
device_actions[action_name] = {
"type_name": action_info.get("type_name", ""),
"action_path": action_info.get("action_path", ""),
"is_busy": is_busy,
"current_job_id": current_job[:8] if current_job else None,
}
total_action_count += 1
except Exception as e:
logger.warning(f"[Controller] Error processing action {device_id}/{action_name}: {str(e)}")
if device_actions:
all_actions[device_id] = {
"actions": device_actions,
"action_count": len(device_actions),
"machine_name": host_node.device_machine_names.get(device_id, "未知"),
}
return True, {
"devices": all_actions,
"device_count": len(all_actions),
"total_action_count": total_action_count,
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"[Controller] Error getting all available actions: {str(e)}")
traceback.print_exc()
return False, {"error": str(e)}