mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-17 13:01:12 +00:00
* Cleanup registry to be easy-understanding (#76) * delete deprecated mock devices * rename categories * combine chromatographic devices * rename rviz simulation nodes * organic virtual devices * parse vessel_id * run registry completion before merge --------- Co-authored-by: Xuwznln <18435084+Xuwznln@users.noreply.github.com> * fix: workstation handlers and vessel_id parsing * fix: working dir error when input config path feat: report publish topic when error * modify default discovery_interval to 15s * feat: add trace log level * feat: 添加ChinWe设备控制类,支持串口通信和电机控制功能 (#79) * fix: drop_tips not using auto resource select * fix: discard_tips error * fix: discard_tips * fix: prcxi_res * add: prcxi res fix: startup slow * feat: workstation example * fix pumps and liquid_handler handle * feat: 优化protocol node节点运行日志 * fix all protocol_compilers and remove deprecated devices * feat: 新增use_remote_resource参数 * fix and remove redundant info * bugfixes on organic protocols * fix filter protocol * fix protocol node * 临时兼容错误的driver写法 * fix: prcxi import error * use call_async in all service to avoid deadlock * fix: figure_resource * Update recipe.yaml * add workstation template and battery example * feat: add sk & ak * update workstation base * Create workstation_architecture.md * refactor: workstation_base 重构为仅含业务逻辑,通信和子设备管理交给 ProtocolNode * refactor: ProtocolNode→WorkstationNode * Add:msgs.action (#83) * update: Workstation dev 将版本号从 0.10.3 更新为 0.10.4 (#84) * Add:msgs.action * update: 将版本号从 0.10.3 更新为 0.10.4 * simplify resource system * uncompleted refactor * example for use WorkstationBase * feat: websocket * feat: websocket test * feat: workstation example * feat: action status * fix: station自己的方法注册错误 * fix: 还原protocol node处理方法 * fix: build * fix: missing job_id key * ws test version 1 * ws test version 2 * ws protocol * 增加物料关系上传日志 * 增加物料关系上传日志 * 修正物料关系上传 * 修复工站的tracker实例追踪失效问题 * 增加handle检测,增加material edge关系上传 * 修复event loop错误 * 修复edge上报错误 * 修复async错误 * 更新schema的title字段 * 主机节点信息等支持自动刷新 * 注册表编辑器 * 修复status密集发送时,消息出错 * 增加addr参数 * fix: addr param * fix: addr param * 取消labid 和 强制config输入 * Add action definitions for LiquidHandlerSetGroup and LiquidHandlerTransferGroup - Created LiquidHandlerSetGroup.action with fields for group name, wells, and volumes. - Created LiquidHandlerTransferGroup.action with fields for source and target group names and unit volume. - Both actions include response fields for return information and success status. * Add LiquidHandlerSetGroup and LiquidHandlerTransferGroup actions to CMakeLists * Add set_group and transfer_group methods to PRCXI9300Handler and update liquid_handler.yaml * result_info改为字典类型 * 新增uat的地址替换 * runze multiple pump support (cherry picked from commit49354fcf39) * remove runze multiple software obtainer (cherry picked from commit8bcc92a394) * support multiple backbone (cherry picked from commit4771ff2347) * Update runze pump format * Correct runze multiple backbone * Update runze_multiple_backbone * Correct runze pump multiple receive method. * Correct runze pump multiple receive method. * 对于PRCXI9320的transfer_group,一对多和多对多 * 移除MQTT,更新launch文档,提供注册表示例文件,更新到0.10.5 * fix import error * fix dupe upload registry * refactor ws client * add server timeout * Fix: run-column with correct vessel id (#86) * fix run_column * Update run_column_protocol.py (cherry picked from commite5aa4d940a) * resource_update use resource_add * 新增版位推荐功能 * 重新规定了版位推荐的入参 * update registry with nested obj * fix protocol node log_message, added create_resource return value * fix protocol node log_message, added create_resource return value * try fix add protocol * fix resource_add * 修复移液站错误的aspirate注册表 * Feature/xprbalance-zhida (#80) * feat(devices): add Zhida GC/MS pretreatment automation workstation * feat(devices): add mettler_toledo xpr balance * balance * 重新补全zhida注册表 * PRCXI9320 json * PRCXI9320 json * PRCXI9320 json * fix resource download * remove class for resource * bump version to 0.10.6 * 更新所有注册表 * 修复protocolnode的兼容性 * 修复protocolnode的兼容性 * Update install md * Add Defaultlayout * 更新物料接口 * fix dict to tree/nested-dict converter * coin_cell_station draft * refactor: rename "station_resource" to "deck" * add standardized BIOYOND resources: bottle_carrier, bottle * refactor and add BIOYOND resources tests * add BIOYOND deck assignment and pass all tests * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92) * feat: 新威电池测试系统驱动与注册文件 * feat: bring neware driver & battery.json into workstation_dev_YB2 * add bioyond studio draft * bioyond station with communication init and resource sync * fix bioyond station and registry * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * frontend_docs * create/update resources with POST/PUT for big amount/ small amount data * create/update resources with POST/PUT for big amount/ small amount data * refactor: add itemized_carrier instead of carrier consists of ResourceHolder * create warehouse by factory func * update bioyond launch json * add child_size for itemized_carrier * fix bioyond resource io * Workstation templates: Resources and its CRUD, and workstation tasks (#95) * coin_cell_station draft * refactor: rename "station_resource" to "deck" * add standardized BIOYOND resources: bottle_carrier, bottle * refactor and add BIOYOND resources tests * add BIOYOND deck assignment and pass all tests * fix: update resource with correct structure; remove deprecated liquid_handler set_group action * feat: 将新威电池测试系统驱动与配置文件并入 workstation_dev_YB2 (#92) * feat: 新威电池测试系统驱动与注册文件 * feat: bring neware driver & battery.json into workstation_dev_YB2 * add bioyond studio draft * bioyond station with communication init and resource sync * fix bioyond station and registry * create/update resources with POST/PUT for big amount/ small amount data * refactor: add itemized_carrier instead of carrier consists of ResourceHolder * create warehouse by factory func * update bioyond launch json * add child_size for itemized_carrier * fix bioyond resource io --------- Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com> Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com> * 更新物料接口 * Workstation dev yb2 (#100) * Refactor and extend reaction station action messages * Refactor dispensing station tasks to enhance parameter clarity and add batch processing capabilities - Updated `create_90_10_vial_feeding_task` to include detailed parameters for 90%/10% vial feeding, improving clarity and usability. - Introduced `create_batch_90_10_vial_feeding_task` for batch processing of 90%/10% vial feeding tasks with JSON formatted input. - Added `create_batch_diamine_solution_task` for batch preparation of diamine solution, also utilizing JSON formatted input. - Refined `create_diamine_solution_task` to include additional parameters for better task configuration. - Enhanced schema descriptions and default values for improved user guidance. * 修复to_plr_resources * add update remove * 支持选择器注册表自动生成 支持转运物料 * 修复资源添加 * 修复transfer_resource_to_another生成 * 更新transfer_resource_to_another参数,支持spot入参 * 新增test_resource动作 * fix host_node error * fix host_node test_resource error * fix host_node test_resource error * 过滤本地动作 * 移动内部action以兼容host node * 修复同步任务报错不显示的bug * feat: 允许返回非本节点物料,后面可以通过decoration进行区分,就不进行warning了 * update todo * modify bioyond/plr converter, bioyond resource registry, and tests * pass the tests * update todo * add conda-pack-build.yml * add auto install script for conda-pack-build.yml (cherry picked from commit172599adcf) * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * update conda-pack-build.yml * Add version in __init__.py Update conda-pack-build.yml Add create_zip_archive.py * Update conda-pack-build.yml * Update conda-pack-build.yml (with mamba) * Update conda-pack-build.yml * Fix FileNotFoundError * Try fix 'charmap' codec can't encode characters in position 16-23: character maps to <undefined> * Fix unilabos msgs search error * Fix environment_check.py * Update recipe.yaml * Update registry. Update uuid loop figure method. Update install docs. * Fix nested conda pack * Fix one-key installation path error * Bump version to 0.10.7 * Workshop bj (#99) * Add LaiYu Liquid device integration and tests Introduce LaiYu Liquid device implementation, including backend, controllers, drivers, configuration, and resource files. Add hardware connection, tip pickup, and simplified test scripts, as well as experiment and registry configuration for LaiYu Liquid. Documentation and .gitignore for the device are also included. * feat(LaiYu_Liquid): 重构设备模块结构并添加硬件文档 refactor: 重新组织LaiYu_Liquid模块目录结构 docs: 添加SOPA移液器和步进电机控制指令文档 fix: 修正设备配置中的最大体积默认值 test: 新增工作台配置测试用例 chore: 删除过时的测试脚本和配置文件 * add * 重构: 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py 并更新所有导入引用 - 使用 git mv 将 LaiYu_Liquid.py 重命名为 laiyu_liquid_main.py - 更新所有相关文件中的导入引用 - 保持代码功能不变,仅改善命名一致性 - 测试确认所有导入正常工作 * 修复: 在 core/__init__.py 中添加 LaiYuLiquidBackend 导出 - 添加 LaiYuLiquidBackend 到导入列表 - 添加 LaiYuLiquidBackend 到 __all__ 导出列表 - 确保所有主要类都可以正确导入 * 修复大小写文件夹名字 * 电池装配工站二次开发教程(带目录)上传至dev (#94) * 电池装配工站二次开发教程 * Update intro.md * 物料教程 * 更新物料教程,json格式注释 * Update prcxi driver & fix transfer_liquid mix_times (#90) * Update prcxi driver & fix transfer_liquid mix_times * fix: correct mix_times type * Update liquid_handler registry * test: prcxi.py * Update registry from pr * fix ony-key script not exist * clean files --------- Co-authored-by: Junhan Chang <changjh@dp.tech> Co-authored-by: ZiWei <131428629+ZiWei09@users.noreply.github.com> Co-authored-by: Guangxin Zhang <guangxin.zhang.bio@gmail.com> Co-authored-by: Xie Qiming <97236197+Andy6M@users.noreply.github.com> Co-authored-by: h840473807 <47357934+h840473807@users.noreply.github.com> Co-authored-by: LccLink <1951855008@qq.com> Co-authored-by: lixinyu1011 <61094742+lixinyu1011@users.noreply.github.com> Co-authored-by: shiyubo0410 <shiyubo@dp.tech>
814 lines
40 KiB
Python
814 lines
40 KiB
Python
import copy
|
||
import io
|
||
import os
|
||
import sys
|
||
import inspect
|
||
import importlib
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Union, Tuple
|
||
|
||
import yaml
|
||
from unilabos_msgs.msg import Resource
|
||
|
||
from unilabos.config.config import BasicConfig
|
||
from unilabos.resources.graphio import resource_plr_to_ulab, tree_to_list
|
||
from unilabos.ros.msgs.message_converter import (
|
||
msg_converter_manager,
|
||
ros_action_to_json_schema,
|
||
String,
|
||
ros_message_to_json_schema,
|
||
)
|
||
from unilabos.utils import logger
|
||
from unilabos.utils.decorator import singleton
|
||
from unilabos.utils.import_manager import get_enhanced_class_info, get_class
|
||
from unilabos.utils.type_check import NoAliasDumper
|
||
|
||
DEFAULT_PATHS = [Path(__file__).absolute().parent]
|
||
|
||
|
||
class ROSMsgNotFound(Exception):
|
||
pass
|
||
|
||
|
||
@singleton
|
||
class Registry:
|
||
def __init__(self, registry_paths=None):
|
||
import ctypes
|
||
|
||
try:
|
||
import unilabos_msgs
|
||
except ImportError:
|
||
logger.error("[UniLab Registry] unilabos_msgs模块未找到,请确保已根据官方文档安装unilabos_msgs包。")
|
||
sys.exit(1)
|
||
try:
|
||
ctypes.CDLL(str(Path(unilabos_msgs.__file__).parent / "unilabos_msgs_s__rosidl_typesupport_c.pyd"))
|
||
except OSError as e:
|
||
pass
|
||
|
||
self.registry_paths = DEFAULT_PATHS.copy() # 使用copy避免修改默认值
|
||
if registry_paths:
|
||
self.registry_paths.extend(registry_paths)
|
||
self.ResourceCreateFromOuter = self._replace_type_with_class(
|
||
"ResourceCreateFromOuter", "host_node", f"动作 create_resource_detailed"
|
||
)
|
||
self.ResourceCreateFromOuterEasy = self._replace_type_with_class(
|
||
"ResourceCreateFromOuterEasy", "host_node", f"动作 create_resource"
|
||
)
|
||
self.EmptyIn = self._replace_type_with_class("EmptyIn", "host_node", f"")
|
||
self.StrSingleInput = self._replace_type_with_class("StrSingleInput", "host_node", f"")
|
||
self.device_type_registry = {}
|
||
self.device_module_to_registry = {}
|
||
self.resource_type_registry = {}
|
||
self._setup_called = False # 跟踪setup是否已调用
|
||
# 其他状态变量
|
||
# self.is_host_mode = False # 移至BasicConfig中
|
||
|
||
def setup(self, complete_registry=False, upload_registry=False):
|
||
# 检查是否已调用过setup
|
||
if self._setup_called:
|
||
logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用")
|
||
return
|
||
|
||
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
|
||
|
||
self.device_type_registry.update(
|
||
{
|
||
"host_node": {
|
||
"description": "UniLabOS主机节点",
|
||
"class": {
|
||
"module": "unilabos.ros.nodes.presets.host_node",
|
||
"type": "python",
|
||
"status_types": {},
|
||
"action_value_mappings": {
|
||
"create_resource_detailed": {
|
||
"type": self.ResourceCreateFromOuter,
|
||
"goal": {
|
||
"resources": "resources",
|
||
"device_ids": "device_ids",
|
||
"bind_parent_ids": "bind_parent_ids",
|
||
"bind_locations": "bind_locations",
|
||
"other_calling_params": "other_calling_params",
|
||
},
|
||
"feedback": {},
|
||
"result": {"success": "success"},
|
||
"schema": ros_action_to_json_schema(
|
||
self.ResourceCreateFromOuter, "用于创建或更新物料资源,每次传入多个物料信息。"
|
||
),
|
||
"goal_default": yaml.safe_load(
|
||
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuter.Goal))
|
||
),
|
||
"handles": {},
|
||
},
|
||
"create_resource": {
|
||
"type": self.ResourceCreateFromOuterEasy,
|
||
"goal": {
|
||
"res_id": "res_id",
|
||
"class_name": "class_name",
|
||
"parent": "parent",
|
||
"device_id": "device_id",
|
||
"bind_locations": "bind_locations",
|
||
"liquid_input_slot": "liquid_input_slot[]",
|
||
"liquid_type": "liquid_type[]",
|
||
"liquid_volume": "liquid_volume[]",
|
||
"slot_on_deck": "slot_on_deck",
|
||
},
|
||
"feedback": {},
|
||
"result": {"success": "success"},
|
||
"schema": ros_action_to_json_schema(
|
||
self.ResourceCreateFromOuterEasy, "用于创建或更新物料资源,每次传入一个物料信息。"
|
||
),
|
||
"goal_default": yaml.safe_load(
|
||
io.StringIO(get_yaml_from_goal_type(self.ResourceCreateFromOuterEasy.Goal))
|
||
),
|
||
"handles": {
|
||
"output": [
|
||
{
|
||
"handler_key": "labware",
|
||
"label": "Labware",
|
||
"data_type": "resource",
|
||
"data_source": "handle",
|
||
"data_key": "liquid",
|
||
}
|
||
]
|
||
},
|
||
"placeholder_keys": {
|
||
"res_id": "unilabos_resources", # 将当前实验室的全部物料id作为下拉框可选择
|
||
"device_id": "unilabos_devices", # 将当前实验室的全部设备id作为下拉框可选择
|
||
"parent": "unilabos_nodes", # 将当前实验室的设备/物料作为下拉框可选择
|
||
},
|
||
},
|
||
"test_latency": {
|
||
"type": self.EmptyIn,
|
||
"goal": {},
|
||
"feedback": {},
|
||
"result": {},
|
||
"schema": ros_action_to_json_schema(
|
||
self.EmptyIn, "用于测试延迟的动作,返回延迟时间和时间差。"
|
||
),
|
||
"goal_default": {},
|
||
"handles": {},
|
||
},
|
||
"auto-test_resource": {
|
||
"type": "UniLabJsonCommand",
|
||
"goal": {},
|
||
"feedback": {},
|
||
"result": {},
|
||
"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",
|
||
"resource": "unilabos_resources",
|
||
"resources": "unilabos_resources",
|
||
},
|
||
"goal_default": {},
|
||
"handles": {},
|
||
},
|
||
},
|
||
},
|
||
"version": "1.0.0",
|
||
"category": [],
|
||
"config_info": [],
|
||
"icon": "icon_device.webp",
|
||
"registry_type": "device",
|
||
"handles": [], # virtue采用了不同的handle
|
||
"init_param_schema": {},
|
||
"file_path": "/",
|
||
}
|
||
}
|
||
)
|
||
# 为host_node添加内置的驱动命令动作
|
||
self._add_builtin_actions(self.device_type_registry["host_node"], "host_node")
|
||
logger.trace(f"[UniLab Registry] ----------Setup----------")
|
||
self.registry_paths = [Path(path).absolute() for path in self.registry_paths]
|
||
for i, path in enumerate(self.registry_paths):
|
||
sys_path = path.parent
|
||
logger.trace(f"[UniLab Registry] Path {i+1}/{len(self.registry_paths)}: {sys_path}")
|
||
sys.path.append(str(sys_path))
|
||
self.load_device_types(path, complete_registry)
|
||
if BasicConfig.enable_resource_load:
|
||
self.load_resource_types(path, complete_registry, upload_registry)
|
||
else:
|
||
logger.warning("跳过了资源注册表加载!")
|
||
logger.info("[UniLab Registry] 注册表设置完成")
|
||
# 标记setup已被调用
|
||
self._setup_called = True
|
||
|
||
def load_resource_types(self, path: os.PathLike, complete_registry: bool, upload_registry: bool):
|
||
abs_path = Path(path).absolute()
|
||
resource_path = abs_path / "resources"
|
||
files = list(resource_path.glob("*/*.yaml"))
|
||
logger.debug(f"[UniLab Registry] resources: {resource_path.exists()}, total: {len(files)}")
|
||
current_resource_number = len(self.resource_type_registry) + 1
|
||
for i, file in enumerate(files):
|
||
with open(file, encoding="utf-8", mode="r") as f:
|
||
data = yaml.safe_load(io.StringIO(f.read()))
|
||
complete_data = {}
|
||
if data:
|
||
# 为每个资源添加文件路径信息
|
||
for resource_id, resource_info in data.items():
|
||
if "version" not in resource_info:
|
||
resource_info["version"] = "1.0.0"
|
||
if "category" not in resource_info:
|
||
resource_info["category"] = [file.stem]
|
||
elif file.stem not in resource_info["category"]:
|
||
resource_info["category"].append(file.stem)
|
||
if "config_info" not in resource_info:
|
||
resource_info["config_info"] = []
|
||
if "icon" not in resource_info:
|
||
resource_info["icon"] = ""
|
||
if "handles" not in resource_info:
|
||
resource_info["handles"] = []
|
||
if "init_param_schema" not in resource_info:
|
||
resource_info["init_param_schema"] = {}
|
||
if "config_info" in resource_info:
|
||
del resource_info["config_info"]
|
||
if "file_path" in resource_info:
|
||
del resource_info["file_path"]
|
||
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
|
||
if upload_registry:
|
||
class_info = resource_info.get("class", {})
|
||
if len(class_info) and "module" in class_info:
|
||
if class_info.get("type") == "pylabrobot":
|
||
res_class = get_class(class_info["module"])
|
||
if callable(res_class) and not isinstance(
|
||
res_class, type
|
||
): # 有的是类,有的是函数,这里暂时只登记函数类的
|
||
res_instance = res_class(res_class.__name__)
|
||
res_ulr = tree_to_list([resource_plr_to_ulab(res_instance)])
|
||
resource_info["config_info"] = res_ulr
|
||
resource_info["registry_type"] = "resource"
|
||
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
|
||
complete_data = dict(sorted(complete_data.items()))
|
||
complete_data = copy.deepcopy(complete_data)
|
||
if complete_registry:
|
||
with open(file, "w", encoding="utf-8") as f:
|
||
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
|
||
|
||
self.resource_type_registry.update(data)
|
||
logger.trace( # type: ignore
|
||
f"[UniLab Registry] Resource-{current_resource_number} File-{i+1}/{len(files)} "
|
||
+ f"Add {list(data.keys())}"
|
||
)
|
||
current_resource_number += 1
|
||
else:
|
||
logger.debug(f"[UniLab Registry] Res File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}")
|
||
|
||
def _extract_class_docstrings(self, module_string: str) -> Dict[str, str]:
|
||
"""
|
||
从模块字符串中提取类和方法的docstring信息
|
||
|
||
Args:
|
||
module_string: 模块字符串,格式为 "module.path:ClassName"
|
||
|
||
Returns:
|
||
包含类和方法docstring信息的字典
|
||
"""
|
||
docstrings = {"class_docstring": "", "methods": {}}
|
||
|
||
if not module_string or ":" not in module_string:
|
||
return docstrings
|
||
|
||
try:
|
||
module_path, class_name = module_string.split(":", 1)
|
||
|
||
# 动态导入模块
|
||
module = importlib.import_module(module_path)
|
||
|
||
# 获取类
|
||
if hasattr(module, class_name):
|
||
cls = getattr(module, class_name)
|
||
|
||
# 获取类的docstring
|
||
class_doc = inspect.getdoc(cls)
|
||
if class_doc:
|
||
docstrings["class_docstring"] = class_doc.strip()
|
||
|
||
# 获取所有方法的docstring
|
||
for method_name, method in inspect.getmembers(cls, predicate=inspect.isfunction):
|
||
method_doc = inspect.getdoc(method)
|
||
if method_doc:
|
||
docstrings["methods"][method_name] = method_doc.strip()
|
||
|
||
# 也获取属性方法的docstring
|
||
for method_name, method in inspect.getmembers(cls, predicate=lambda x: isinstance(x, property)):
|
||
if hasattr(method, "fget") and method.fget:
|
||
method_doc = inspect.getdoc(method.fget)
|
||
if method_doc:
|
||
docstrings["methods"][method_name] = method_doc.strip()
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[UniLab Registry] 无法提取docstring信息,模块: {module_string}, 错误: {str(e)}")
|
||
|
||
return docstrings
|
||
|
||
def _replace_type_with_class(self, type_name: str, device_id: str, field_name: str) -> Any:
|
||
"""
|
||
将类型名称替换为实际的类对象
|
||
|
||
Args:
|
||
type_name: 类型名称
|
||
device_id: 设备ID,用于错误信息
|
||
field_name: 字段名称,用于错误信息
|
||
|
||
Returns:
|
||
找到的类对象或原始字符串
|
||
|
||
Raises:
|
||
SystemExit: 如果找不到类型则终止程序
|
||
"""
|
||
# 如果类型名为空,跳过替换
|
||
if not type_name or type_name == "":
|
||
logger.warning(f"[UniLab Registry] 设备 {device_id} 的 {field_name} 类型为空,跳过替换")
|
||
return type_name
|
||
convert_manager = { # 将python基本对象转为ros2基本对象
|
||
"str": "String",
|
||
"bool": "Bool",
|
||
"int": "Int64",
|
||
"float": "Float64",
|
||
}
|
||
type_name = convert_manager.get(type_name, type_name) # 替换为ROS2类型
|
||
if ":" in type_name:
|
||
type_class = msg_converter_manager.get_class(type_name)
|
||
else:
|
||
type_class = msg_converter_manager.search_class(type_name)
|
||
if type_class:
|
||
return type_class
|
||
else:
|
||
logger.error(f"[UniLab Registry] 无法找到类型 '{type_name}' 用于设备 {device_id} 的 {field_name}")
|
||
raise ROSMsgNotFound(f"类型 '{type_name}' 未找到,用于设备 {device_id} 的 {field_name}")
|
||
|
||
def _get_json_schema_type(self, type_str: str) -> str:
|
||
"""
|
||
根据类型字符串返回对应的JSON Schema类型
|
||
|
||
Args:
|
||
type_str: 类型字符串
|
||
|
||
Returns:
|
||
JSON Schema类型字符串
|
||
"""
|
||
type_lower = type_str.lower()
|
||
type_mapping = {
|
||
("str", "string"): "string",
|
||
("int", "integer"): "integer",
|
||
("float", "number"): "number",
|
||
("bool", "boolean"): "boolean",
|
||
("list", "array"): "array",
|
||
("dict", "object"): "object",
|
||
}
|
||
|
||
# 遍历映射找到匹配的类型
|
||
for type_variants, json_type in type_mapping.items():
|
||
if type_lower in type_variants:
|
||
return json_type
|
||
|
||
# 特殊处理包含冒号的类型(如ROS消息类型)
|
||
if ":" in type_lower:
|
||
return "object"
|
||
|
||
# 默认返回字符串类型
|
||
return "string"
|
||
|
||
def _generate_schema_from_info(
|
||
self,
|
||
param_name: str,
|
||
param_type: Union[str, Tuple[str]],
|
||
param_default: Any,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
根据参数信息生成JSON Schema
|
||
"""
|
||
prop_schema = {}
|
||
|
||
# 处理嵌套类型(Tuple[str])
|
||
if isinstance(param_type, tuple):
|
||
if len(param_type) == 2:
|
||
outer_type, inner_type = param_type
|
||
outer_json_type = self._get_json_schema_type(outer_type)
|
||
inner_json_type = self._get_json_schema_type(inner_type)
|
||
|
||
prop_schema["type"] = outer_json_type
|
||
|
||
# 根据外层类型设置内层类型信息
|
||
if outer_json_type == "array":
|
||
prop_schema["items"] = {"type": inner_json_type}
|
||
elif outer_json_type == "object":
|
||
prop_schema["additionalProperties"] = {"type": inner_json_type}
|
||
else:
|
||
# 不是标准的嵌套类型,默认为字符串
|
||
prop_schema["type"] = "string"
|
||
else:
|
||
# 处理非嵌套类型
|
||
if param_type:
|
||
prop_schema["type"] = self._get_json_schema_type(param_type)
|
||
else:
|
||
# 如果没有类型信息,默认为字符串
|
||
prop_schema["type"] = "string"
|
||
|
||
# 设置默认值
|
||
if param_default is not None:
|
||
prop_schema["default"] = param_default
|
||
|
||
return prop_schema
|
||
|
||
def _generate_status_types_schema(self, status_types: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
根据状态类型生成JSON Schema
|
||
"""
|
||
status_schema = {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": [],
|
||
}
|
||
for status_name, status_type in status_types.items():
|
||
status_schema["properties"][status_name] = self._generate_schema_from_info(
|
||
status_name, status_type["return_type"], None
|
||
)
|
||
status_schema["required"].append(status_name)
|
||
return status_schema
|
||
|
||
def _generate_unilab_json_command_schema(
|
||
self, method_args: List[Dict[str, Any]], method_name: str
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
根据UniLabJsonCommand方法信息生成JSON Schema,暂不支持嵌套类型
|
||
|
||
Args:
|
||
method_args: 方法信息字典,包含args等
|
||
method_name: 方法名称
|
||
|
||
Returns:
|
||
JSON Schema格式的参数schema
|
||
"""
|
||
schema = {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": [],
|
||
}
|
||
for arg_info in method_args:
|
||
param_name = arg_info.get("name", "")
|
||
param_type = arg_info.get("type", "")
|
||
param_default = arg_info.get("default")
|
||
param_required = arg_info.get("required", True)
|
||
if param_type == "unilabos.registry.placeholder_type:ResourceSlot":
|
||
schema["properties"][param_name] = ros_message_to_json_schema(Resource, param_name)
|
||
elif param_type == ("list", "unilabos.registry.placeholder_type:ResourceSlot"):
|
||
schema["properties"][param_name] = {
|
||
"items": ros_message_to_json_schema(Resource, param_name),
|
||
"type": "array",
|
||
}
|
||
else:
|
||
schema["properties"][param_name] = self._generate_schema_from_info(
|
||
param_name, param_type, param_default
|
||
)
|
||
if param_required:
|
||
schema["required"].append(param_name)
|
||
|
||
return {
|
||
"title": f"{method_name}参数",
|
||
"description": f"",
|
||
"type": "object",
|
||
"properties": {"goal": schema, "feedback": {}, "result": {}},
|
||
"required": ["goal"],
|
||
}
|
||
|
||
def _add_builtin_actions(self, device_config: Dict[str, Any], device_id: str):
|
||
"""
|
||
为设备配置添加内置的执行驱动命令动作
|
||
|
||
Args:
|
||
device_config: 设备配置字典
|
||
device_id: 设备ID
|
||
"""
|
||
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
|
||
|
||
if "class" not in device_config:
|
||
return
|
||
|
||
if "action_value_mappings" not in device_config["class"]:
|
||
device_config["class"]["action_value_mappings"] = {}
|
||
|
||
for additional_action in ["_execute_driver_command", "_execute_driver_command_async"]:
|
||
device_config["class"]["action_value_mappings"][additional_action] = {
|
||
"type": self._replace_type_with_class("StrSingleInput", device_id, f"动作 {additional_action}"),
|
||
"goal": {"string": "string"},
|
||
"feedback": {},
|
||
"result": {},
|
||
"schema": ros_action_to_json_schema(
|
||
self._replace_type_with_class("StrSingleInput", device_id, f"动作 {additional_action}")
|
||
),
|
||
"goal_default": yaml.safe_load(
|
||
io.StringIO(
|
||
get_yaml_from_goal_type(
|
||
self._replace_type_with_class(
|
||
"StrSingleInput", device_id, f"动作 {additional_action}"
|
||
).Goal
|
||
)
|
||
)
|
||
),
|
||
"handles": {},
|
||
}
|
||
|
||
def load_device_types(self, path: os.PathLike, complete_registry: bool):
|
||
# return
|
||
abs_path = Path(path).absolute()
|
||
devices_path = abs_path / "devices"
|
||
device_comms_path = abs_path / "device_comms"
|
||
files = list(devices_path.glob("*.yaml")) + list(device_comms_path.glob("*.yaml"))
|
||
logger.trace( # type: ignore
|
||
f"[UniLab Registry] devices: {devices_path.exists()}, device_comms: {device_comms_path.exists()}, "
|
||
+ f"total: {len(files)}"
|
||
)
|
||
current_device_number = len(self.device_type_registry) + 1
|
||
from unilabos.app.web.utils.action_utils import get_yaml_from_goal_type
|
||
|
||
for i, file in enumerate(files):
|
||
with open(file, encoding="utf-8", mode="r") as f:
|
||
data = yaml.safe_load(io.StringIO(f.read()))
|
||
complete_data = {}
|
||
action_str_type_mapping = {
|
||
"UniLabJsonCommand": "UniLabJsonCommand",
|
||
"UniLabJsonCommandAsync": "UniLabJsonCommandAsync",
|
||
}
|
||
status_str_type_mapping = {}
|
||
if data:
|
||
# 在添加到注册表前处理类型替换
|
||
for device_id, device_config in data.items():
|
||
# 添加文件路径信息 - 使用规范化的完整文件路径
|
||
if "version" not in device_config:
|
||
device_config["version"] = "1.0.0"
|
||
if "category" not in device_config:
|
||
device_config["category"] = [file.stem]
|
||
elif file.stem not in device_config["category"]:
|
||
device_config["category"].append(file.stem)
|
||
if "config_info" not in device_config:
|
||
device_config["config_info"] = []
|
||
if "description" not in device_config:
|
||
device_config["description"] = ""
|
||
if "icon" not in device_config:
|
||
device_config["icon"] = ""
|
||
if "handles" not in device_config:
|
||
device_config["handles"] = []
|
||
if "init_param_schema" not in device_config:
|
||
device_config["init_param_schema"] = {}
|
||
if "class" in device_config:
|
||
if "status_types" not in device_config["class"]:
|
||
device_config["class"]["status_types"] = {}
|
||
if "action_value_mappings" not in device_config["class"]:
|
||
device_config["class"]["action_value_mappings"] = {}
|
||
enhanced_info = {}
|
||
if complete_registry:
|
||
device_config["class"]["status_types"].clear()
|
||
enhanced_info = get_enhanced_class_info(device_config["class"]["module"], use_dynamic=True)
|
||
if not enhanced_info.get("dynamic_import_success", False):
|
||
continue
|
||
device_config["class"]["status_types"].update(
|
||
{k: v["return_type"] for k, v in enhanced_info["status_methods"].items()}
|
||
)
|
||
for status_name, status_type in device_config["class"]["status_types"].items():
|
||
if isinstance(status_type, tuple) or status_type in ["Any", "None", "Unknown"]:
|
||
status_type = "String" # 替换成ROS的String,便于显示
|
||
device_config["class"]["status_types"][status_name] = status_type
|
||
try:
|
||
target_type = self._replace_type_with_class(
|
||
status_type, device_id, f"状态 {status_name}"
|
||
)
|
||
except ROSMsgNotFound:
|
||
continue
|
||
if target_type in [
|
||
dict,
|
||
list,
|
||
]: # 对于嵌套类型返回的对象,暂时处理成字符串,无法直接进行转换
|
||
target_type = String
|
||
status_str_type_mapping[status_type] = target_type
|
||
device_config["class"]["status_types"] = dict(
|
||
sorted(device_config["class"]["status_types"].items())
|
||
)
|
||
if complete_registry:
|
||
# 保存原有的description信息
|
||
old_descriptions = {}
|
||
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
||
if "description" in action_config.get("schema", {}):
|
||
description = action_config["schema"]["description"]
|
||
if len(description):
|
||
old_descriptions[action_name] = action_config["schema"]["description"]
|
||
|
||
device_config["class"]["action_value_mappings"] = {
|
||
k: v
|
||
for k, v in device_config["class"]["action_value_mappings"].items()
|
||
if not k.startswith("auto-")
|
||
}
|
||
# 处理动作值映射
|
||
device_config["class"]["action_value_mappings"].update(
|
||
{
|
||
f"auto-{k}": {
|
||
"type": "UniLabJsonCommandAsync" if v["is_async"] else "UniLabJsonCommand",
|
||
"goal": {},
|
||
"feedback": {},
|
||
"result": {},
|
||
"schema": self._generate_unilab_json_command_schema(v["args"], k),
|
||
"goal_default": {i["name"]: i["default"] for i in v["args"]},
|
||
"handles": [],
|
||
"placeholder_keys": {
|
||
i["name"]: (
|
||
"unilabos_resources"
|
||
if i["type"] == "unilabos.registry.placeholder_type:ResourceSlot"
|
||
or i["type"]
|
||
== ("list", "unilabos.registry.placeholder_type:ResourceSlot")
|
||
else "unilabos_devices"
|
||
)
|
||
for i in v["args"]
|
||
if i.get("type", "")
|
||
in [
|
||
"unilabos.registry.placeholder_type:ResourceSlot",
|
||
"unilabos.registry.placeholder_type:DeviceSlot",
|
||
("list", "unilabos.registry.placeholder_type:ResourceSlot"),
|
||
("list", "unilabos.registry.placeholder_type:DeviceSlot"),
|
||
]
|
||
},
|
||
}
|
||
# 不生成已配置action的动作
|
||
for k, v in enhanced_info["action_methods"].items()
|
||
if k not in device_config["class"]["action_value_mappings"]
|
||
}
|
||
)
|
||
# 恢复原有的description信息(auto开头的不修改)
|
||
for action_name, description in old_descriptions.items():
|
||
if action_name in device_config["class"]["action_value_mappings"]: # 有一些会被删除
|
||
device_config["class"]["action_value_mappings"][action_name]["schema"][
|
||
"description"
|
||
] = description
|
||
device_config["init_param_schema"] = {}
|
||
device_config["init_param_schema"]["config"] = self._generate_unilab_json_command_schema(
|
||
enhanced_info["init_params"], "__init__"
|
||
)["properties"]["goal"]
|
||
device_config["init_param_schema"]["data"] = self._generate_status_types_schema(
|
||
enhanced_info["status_methods"]
|
||
)
|
||
|
||
device_config.pop("schema", None)
|
||
device_config["class"]["action_value_mappings"] = dict(
|
||
sorted(device_config["class"]["action_value_mappings"].items())
|
||
)
|
||
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
||
if "handles" not in action_config:
|
||
action_config["handles"] = {}
|
||
elif isinstance(action_config["handles"], list):
|
||
if len(action_config["handles"]):
|
||
logger.error(f"设备{device_id} {action_name} 的handles配置错误,应该是字典类型")
|
||
continue
|
||
else:
|
||
action_config["handles"] = {}
|
||
if "type" in action_config:
|
||
action_type_str: str = action_config["type"]
|
||
# 通过Json发放指令,而不是通过特殊的ros action进行处理
|
||
if not action_type_str.startswith("UniLabJsonCommand"):
|
||
try:
|
||
target_type = self._replace_type_with_class(
|
||
action_type_str, device_id, f"动作 {action_name}"
|
||
)
|
||
except ROSMsgNotFound:
|
||
continue
|
||
action_str_type_mapping[action_type_str] = target_type
|
||
if target_type is not None:
|
||
action_config["goal_default"] = yaml.safe_load(
|
||
io.StringIO(get_yaml_from_goal_type(target_type.Goal))
|
||
)
|
||
action_config["schema"] = ros_action_to_json_schema(target_type)
|
||
else:
|
||
logger.warning(
|
||
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
|
||
)
|
||
complete_data[device_id] = copy.deepcopy(dict(sorted(device_config.items()))) # 稍后dump到文件
|
||
for status_name, status_type in device_config["class"]["status_types"].items():
|
||
device_config["class"]["status_types"][status_name] = status_str_type_mapping[status_type]
|
||
for action_name, action_config in device_config["class"]["action_value_mappings"].items():
|
||
action_config["type"] = action_str_type_mapping[action_config["type"]]
|
||
# 添加内置的驱动命令动作
|
||
self._add_builtin_actions(device_config, device_id)
|
||
device_config["file_path"] = str(file.absolute()).replace("\\", "/")
|
||
device_config["registry_type"] = "device"
|
||
logger.trace( # type: ignore
|
||
f"[UniLab Registry] Device-{current_device_number} File-{i+1}/{len(files)} Add {device_id} "
|
||
+ f"[{data[device_id].get('name', '未命名设备')}]"
|
||
)
|
||
current_device_number += 1
|
||
complete_data = dict(sorted(complete_data.items()))
|
||
complete_data = copy.deepcopy(complete_data)
|
||
with open(file, "w", encoding="utf-8") as f:
|
||
yaml.dump(complete_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
|
||
self.device_type_registry.update(data)
|
||
else:
|
||
logger.debug(
|
||
f"[UniLab Registry] Device File-{i+1}/{len(files)} Not Valid YAML File: {file.absolute()}"
|
||
)
|
||
|
||
def obtain_registry_device_info(self):
|
||
devices = []
|
||
for device_id, device_info in self.device_type_registry.items():
|
||
device_info_copy = copy.deepcopy(device_info)
|
||
if "class" in device_info_copy and "action_value_mappings" in device_info_copy["class"]:
|
||
action_mappings = device_info_copy["class"]["action_value_mappings"]
|
||
# 过滤掉内置的驱动命令动作
|
||
builtin_actions = ["_execute_driver_command", "_execute_driver_command_async"]
|
||
filtered_action_mappings = {
|
||
action_name: action_config
|
||
for action_name, action_config in action_mappings.items()
|
||
if action_name not in builtin_actions
|
||
}
|
||
device_info_copy["class"]["action_value_mappings"] = filtered_action_mappings
|
||
|
||
for action_name, action_config in filtered_action_mappings.items():
|
||
if "schema" in action_config and action_config["schema"]:
|
||
schema = action_config["schema"]
|
||
# 确保schema结构存在
|
||
if (
|
||
"properties" in schema
|
||
and "goal" in schema["properties"]
|
||
and "properties" in schema["properties"]["goal"]
|
||
):
|
||
schema["properties"]["goal"]["properties"] = {
|
||
"unilabos_device_id": {
|
||
"type": "string",
|
||
"default": "",
|
||
"description": "UniLabOS设备ID,用于指定执行动作的具体设备实例",
|
||
},
|
||
**schema["properties"]["goal"]["properties"],
|
||
}
|
||
# 将 placeholder_keys 信息添加到 schema 中
|
||
if "placeholder_keys" in action_config and action_config.get("schema", {}).get(
|
||
"properties", {}
|
||
).get("goal", {}):
|
||
action_config["schema"]["properties"]["goal"]["_unilabos_placeholder_info"] = action_config[
|
||
"placeholder_keys"
|
||
]
|
||
|
||
msg = {"id": device_id, **device_info_copy}
|
||
devices.append(msg)
|
||
return devices
|
||
|
||
def obtain_registry_resource_info(self):
|
||
resources = []
|
||
for resource_id, resource_info in self.resource_type_registry.items():
|
||
msg = {"id": resource_id, **resource_info}
|
||
resources.append(msg)
|
||
return resources
|
||
|
||
|
||
# 全局单例实例
|
||
lab_registry = Registry()
|
||
|
||
|
||
def build_registry(registry_paths=None, complete_registry=False, upload_registry=False):
|
||
"""
|
||
构建或获取Registry单例实例
|
||
|
||
Args:
|
||
registry_paths: 额外的注册表路径列表
|
||
|
||
Returns:
|
||
Registry实例
|
||
"""
|
||
logger.info("[UniLab Registry] 构建注册表实例")
|
||
|
||
# 由于使用了单例,这里不需要重新创建实例
|
||
global lab_registry
|
||
|
||
# 如果有额外路径,添加到registry_paths
|
||
if registry_paths:
|
||
current_paths = lab_registry.registry_paths.copy()
|
||
# 检查是否有新路径需要添加
|
||
for path in registry_paths:
|
||
if path not in current_paths:
|
||
lab_registry.registry_paths.append(path)
|
||
|
||
# 初始化注册表
|
||
lab_registry.setup(complete_registry, upload_registry)
|
||
|
||
return lab_registry
|