mirror of
https://github.com/deepmodeling/Uni-Lab-OS
synced 2026-03-24 09:39:17 +00:00
2224 lines
99 KiB
Python
2224 lines
99 KiB
Python
"""
|
||
统一注册表系统
|
||
|
||
合并了原 Registry (YAML 加载) 和 DecoratorRegistry (装饰器/AST 扫描) 的功能,
|
||
提供单一入口来构建、验证和查询设备/资源注册表。
|
||
"""
|
||
|
||
import copy
|
||
import importlib
|
||
import inspect
|
||
import io
|
||
import os
|
||
import sys
|
||
import threading
|
||
import time
|
||
import traceback
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||
|
||
import yaml
|
||
from unilabos_msgs.action import EmptyIn, ResourceCreateFromOuter, ResourceCreateFromOuterEasy
|
||
from unilabos_msgs.msg import Resource
|
||
|
||
from unilabos.config.config import BasicConfig
|
||
from unilabos.registry.decorators import (
|
||
get_device_meta,
|
||
get_action_meta,
|
||
get_resource_meta,
|
||
has_action_decorator,
|
||
get_all_registered_devices,
|
||
get_all_registered_resources,
|
||
is_not_action,
|
||
is_always_free,
|
||
get_topic_config,
|
||
)
|
||
from unilabos.registry.utils import (
|
||
ROSMsgNotFound,
|
||
parse_docstring,
|
||
get_json_schema_type,
|
||
parse_type_node,
|
||
type_node_to_schema,
|
||
resolve_type_object,
|
||
type_to_schema,
|
||
detect_slot_type,
|
||
detect_placeholder_keys,
|
||
normalize_ast_handles,
|
||
normalize_ast_action_handles,
|
||
wrap_action_schema,
|
||
preserve_field_descriptions,
|
||
resolve_method_params_via_import,
|
||
SIMPLE_TYPE_MAP,
|
||
)
|
||
from unilabos.resources.graphio import resource_plr_to_ulab, tree_to_list
|
||
from unilabos.resources.resource_tracker import ResourceTreeSet
|
||
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.cls_creator import import_class
|
||
from unilabos.utils.import_manager import get_enhanced_class_info
|
||
from unilabos.utils.type_check import NoAliasDumper
|
||
from msgcenterpy.instances.json_schema_instance import JSONSchemaMessageInstance
|
||
from msgcenterpy.instances.ros2_instance import ROS2MessageInstance
|
||
|
||
_module_hash_cache: Dict[str, Optional[str]] = {}
|
||
|
||
|
||
@singleton
|
||
class Registry:
|
||
"""
|
||
统一注册表。
|
||
|
||
核心流程:
|
||
1. AST 静态扫描 @device/@resource 装饰器 (快速, 无需 import)
|
||
2. 加载 YAML 注册表 (兼容旧格式)
|
||
3. 设置 host_node 内置设备
|
||
4. verify & resolve (实际 import 验证 + 类型解析)
|
||
"""
|
||
|
||
def __init__(self, registry_paths=None):
|
||
import ctypes
|
||
|
||
try:
|
||
# noinspection PyUnusedImports
|
||
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:
|
||
pass
|
||
|
||
self.registry_paths = [Path(__file__).absolute().parent]
|
||
if registry_paths:
|
||
self.registry_paths.extend(registry_paths)
|
||
logger.debug(f"[UniLab Registry] registry_paths: {self.registry_paths}")
|
||
|
||
self.device_type_registry: Dict[str, Any] = {}
|
||
self.resource_type_registry: Dict[str, Any] = {}
|
||
self._type_resolve_cache: Dict[str, Any] = {}
|
||
|
||
self._setup_called = False
|
||
self._startup_executor: Optional[ThreadPoolExecutor] = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 统一入口
|
||
# ------------------------------------------------------------------
|
||
|
||
def setup(self, devices_dirs=None, upload_registry=False, complete_registry=False, external_only=False):
|
||
"""统一构建注册表入口。"""
|
||
if self._setup_called:
|
||
logger.critical("[UniLab Registry] setup方法已被调用过,不允许多次调用")
|
||
return
|
||
|
||
self._startup_executor = ThreadPoolExecutor(
|
||
max_workers=8, thread_name_prefix="RegistryStartup"
|
||
)
|
||
|
||
# 1. AST 静态扫描 (快速, 无需 import)
|
||
self._run_ast_scan(devices_dirs, upload_registry=upload_registry, external_only=external_only)
|
||
|
||
# 2. Host node 内置设备
|
||
self._setup_host_node()
|
||
|
||
# 3. YAML 注册表加载 (兼容旧格式) — external_only 模式下跳过
|
||
if external_only:
|
||
logger.info("[UniLab Registry] external_only 模式: 跳过 YAML 注册表加载")
|
||
else:
|
||
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=complete_registry)
|
||
if BasicConfig.enable_resource_load:
|
||
self.load_resource_types(path, upload_registry, complete_registry=complete_registry)
|
||
else:
|
||
logger.warning(
|
||
"[UniLab Registry] 资源加载已禁用 (enable_resource_load=False),跳过资源注册表加载"
|
||
)
|
||
self._startup_executor.shutdown(wait=True)
|
||
self._startup_executor = None
|
||
self._setup_called = True
|
||
logger.trace(f"[UniLab Registry] ----------Setup Complete----------")
|
||
|
||
# ------------------------------------------------------------------
|
||
# Host node 设置
|
||
# ------------------------------------------------------------------
|
||
|
||
def _setup_host_node(self):
|
||
"""设置 host_node 内置设备 — 基于 _run_ast_scan 已扫描的结果进行覆写。"""
|
||
# 从 AST 扫描结果中取出 host_node 的 action_value_mappings
|
||
ast_entry = self.device_type_registry.get("host_node", {})
|
||
ast_actions = ast_entry.get("class", {}).get("action_value_mappings", {})
|
||
|
||
# 取出 AST 生成的 auto-method entries, 补充特定覆写
|
||
test_latency_action = ast_actions.get("auto-test_latency", {})
|
||
test_resource_action = ast_actions.get("auto-test_resource", {})
|
||
test_resource_action["handles"] = {
|
||
"input": [
|
||
{
|
||
"handler_key": "input_resources",
|
||
"data_type": "resource",
|
||
"label": "InputResources",
|
||
"data_source": "handle",
|
||
"data_key": "resources",
|
||
},
|
||
]
|
||
}
|
||
|
||
create_resource_action = ast_actions.get("auto-create_resource", {})
|
||
raw_create_resource_schema = ros_action_to_json_schema(
|
||
ResourceCreateFromOuterEasy, "用于创建或更新物料资源,每次传入一个物料信息。"
|
||
)
|
||
raw_create_resource_schema["properties"]["result"] = create_resource_action["schema"]["properties"]["result"]
|
||
|
||
# 覆写: 保留硬编码的 ROS2 action + AST 生成的 auto-method
|
||
self.device_type_registry["host_node"] = {
|
||
"class": {
|
||
"module": "unilabos.ros.nodes.presets.host_node:HostNode",
|
||
"status_types": {},
|
||
"action_value_mappings": {
|
||
"create_resource": {
|
||
"type": ResourceCreateFromOuterEasy,
|
||
"goal": {
|
||
"res_id": "res_id",
|
||
"class_name": "class_name",
|
||
"parent": "parent",
|
||
"device_id": "device_id",
|
||
"bind_locations": "bind_locations",
|
||
"liquid_input_slot": "liquid_input_slot[]",
|
||
"liquid_type": "liquid_type[]",
|
||
"liquid_volume": "liquid_volume[]",
|
||
"slot_on_deck": "slot_on_deck",
|
||
},
|
||
"feedback": {},
|
||
"result": {"success": "success"},
|
||
"schema": raw_create_resource_schema,
|
||
"goal_default": ROS2MessageInstance(ResourceCreateFromOuterEasy.Goal()).get_python_dict(),
|
||
"handles": {
|
||
"output": [
|
||
{
|
||
"handler_key": "labware",
|
||
"data_type": "resource",
|
||
"label": "Labware",
|
||
"data_source": "executor",
|
||
"data_key": "created_resource_tree.@flatten",
|
||
},
|
||
{
|
||
"handler_key": "liquid_slots",
|
||
"data_type": "resource",
|
||
"label": "LiquidSlots",
|
||
"data_source": "executor",
|
||
"data_key": "liquid_input_resource_tree.@flatten",
|
||
},
|
||
{
|
||
"handler_key": "materials",
|
||
"data_type": "resource",
|
||
"label": "AllMaterials",
|
||
"data_source": "executor",
|
||
"data_key": "[created_resource_tree,liquid_input_resource_tree].@flatten.@flatten",
|
||
},
|
||
]
|
||
},
|
||
"placeholder_keys": {
|
||
"res_id": "unilabos_resources",
|
||
"device_id": "unilabos_devices",
|
||
"parent": "unilabos_nodes",
|
||
"class_name": "unilabos_class",
|
||
},
|
||
},
|
||
"test_latency": test_latency_action,
|
||
"auto-test_resource": test_resource_action,
|
||
},
|
||
"init_params": {},
|
||
},
|
||
"version": "1.0.0",
|
||
"category": [],
|
||
"config_info": [],
|
||
"icon": "icon_device.webp",
|
||
"registry_type": "device",
|
||
"description": "Host Node",
|
||
"handles": [],
|
||
"init_param_schema": {},
|
||
"file_path": "/",
|
||
}
|
||
self._add_builtin_actions(self.device_type_registry["host_node"], "host_node")
|
||
|
||
# ------------------------------------------------------------------
|
||
# AST 静态扫描
|
||
# ------------------------------------------------------------------
|
||
|
||
def _run_ast_scan(self, devices_dirs=None, upload_registry=False, external_only=False):
|
||
"""
|
||
执行 AST 静态扫描,从 Python 代码中提取 @device / @resource 装饰器元数据。
|
||
无需 import 任何驱动模块,速度极快。
|
||
|
||
所有缓存(AST 扫描 / build 结果 / config_info)统一存放在
|
||
registry_cache.pkl 一个文件中,删除即可完全重置。
|
||
"""
|
||
import time as _time
|
||
from unilabos.registry.ast_registry_scanner import scan_directory
|
||
|
||
scan_t0 = _time.perf_counter()
|
||
|
||
# 确保 executor 存在
|
||
own_executor = False
|
||
if self._startup_executor is None:
|
||
self._startup_executor = ThreadPoolExecutor(
|
||
max_workers=8, thread_name_prefix="RegistryStartup"
|
||
)
|
||
own_executor = True
|
||
|
||
# ---- 统一缓存:一个 pkl 包含所有数据 ----
|
||
unified_cache = self._load_config_cache()
|
||
ast_cache = unified_cache.setdefault("_ast_scan", {"files": {}})
|
||
|
||
# 默认:扫描 unilabos 包所在的父目录
|
||
pkg_root = Path(__file__).resolve().parent.parent # .../unilabos
|
||
python_path = pkg_root.parent # .../Uni-Lab-OS
|
||
scan_root = pkg_root # 扫描 unilabos/ 整个包
|
||
|
||
# 额外的 --devices 目录:把它们的父目录加入 sys.path
|
||
extra_dirs: list[Path] = []
|
||
if devices_dirs:
|
||
for d in devices_dirs:
|
||
d_path = Path(d).resolve()
|
||
if not d_path.is_dir():
|
||
logger.warning(f"[UniLab Registry] --devices 路径不存在或不是目录: {d_path}")
|
||
continue
|
||
parent_dir = str(d_path.parent)
|
||
if parent_dir not in sys.path:
|
||
sys.path.insert(0, parent_dir)
|
||
logger.info(f"[UniLab Registry] 添加 Python 路径: {parent_dir}")
|
||
extra_dirs.append(d_path)
|
||
|
||
# 主扫描
|
||
if external_only:
|
||
core_files = [
|
||
pkg_root / "ros" / "nodes" / "presets" / "host_node.py",
|
||
pkg_root / "resources" / "container.py",
|
||
]
|
||
scan_result = scan_directory(
|
||
scan_root, python_path=python_path, executor=self._startup_executor,
|
||
cache=ast_cache, include_files=core_files,
|
||
)
|
||
logger.info(
|
||
f"[UniLab Registry] external_only 模式: 仅扫描核心文件 "
|
||
f"({', '.join(f.name for f in core_files)})"
|
||
)
|
||
else:
|
||
exclude_files = {"lab_resources.py"} if not BasicConfig.extra_resource else None
|
||
scan_result = scan_directory(
|
||
scan_root, python_path=python_path, executor=self._startup_executor,
|
||
exclude_files=exclude_files, cache=ast_cache,
|
||
)
|
||
if exclude_files:
|
||
logger.info(
|
||
f"[UniLab Registry] 排除扫描文件: {exclude_files} "
|
||
f"(可通过 --extra_resource 启用加载)"
|
||
)
|
||
|
||
# 合并缓存统计
|
||
total_stats = scan_result.pop("_cache_stats", {"hits": 0, "misses": 0, "total": 0})
|
||
|
||
# 额外目录逐个扫描并合并
|
||
for d_path in extra_dirs:
|
||
extra_result = scan_directory(
|
||
d_path, python_path=str(d_path.parent), executor=self._startup_executor,
|
||
cache=ast_cache,
|
||
)
|
||
extra_stats = extra_result.pop("_cache_stats", {"hits": 0, "misses": 0, "total": 0})
|
||
total_stats["hits"] += extra_stats["hits"]
|
||
total_stats["misses"] += extra_stats["misses"]
|
||
total_stats["total"] += extra_stats["total"]
|
||
|
||
for did, dmeta in extra_result.get("devices", {}).items():
|
||
if did in scan_result.get("devices", {}):
|
||
existing = scan_result["devices"][did].get("file_path", "?")
|
||
new_file = dmeta.get("file_path", "?")
|
||
raise ValueError(
|
||
f"@device id 重复: '{did}' 同时出现在 {existing} 和 {new_file}"
|
||
)
|
||
scan_result.setdefault("devices", {})[did] = dmeta
|
||
for rid, rmeta in extra_result.get("resources", {}).items():
|
||
if rid in scan_result.get("resources", {}):
|
||
existing = scan_result["resources"][rid].get("file_path", "?")
|
||
new_file = rmeta.get("file_path", "?")
|
||
raise ValueError(
|
||
f"@resource id 重复: '{rid}' 同时出现在 {existing} 和 {new_file}"
|
||
)
|
||
scan_result.setdefault("resources", {})[rid] = rmeta
|
||
|
||
# 缓存命中统计
|
||
if total_stats["total"] > 0:
|
||
logger.info(
|
||
f"[UniLab Registry] AST 缓存统计: "
|
||
f"{total_stats['hits']}/{total_stats['total']} 命中, "
|
||
f"{total_stats['misses']} 重新解析"
|
||
)
|
||
|
||
ast_devices = scan_result.get("devices", {})
|
||
ast_resources = scan_result.get("resources", {})
|
||
|
||
# build 结果缓存:当所有 AST 文件命中时跳过 _build_*_entry_from_ast
|
||
all_ast_hit = total_stats["misses"] == 0 and total_stats["total"] > 0
|
||
cached_build = unified_cache.get("_build_results") if all_ast_hit else None
|
||
|
||
if cached_build:
|
||
cached_devices = cached_build.get("devices", {})
|
||
cached_resources = cached_build.get("resources", {})
|
||
if set(cached_devices) == set(ast_devices) and set(cached_resources) == set(ast_resources):
|
||
self.device_type_registry.update(cached_devices)
|
||
self.resource_type_registry.update(cached_resources)
|
||
logger.info(
|
||
f"[UniLab Registry] build 缓存命中: 跳过 {len(cached_devices)} 设备 + "
|
||
f"{len(cached_resources)} 资源的 entry 构建"
|
||
)
|
||
else:
|
||
cached_build = None
|
||
|
||
if not cached_build:
|
||
build_t0 = _time.perf_counter()
|
||
|
||
for device_id, ast_meta in ast_devices.items():
|
||
entry = self._build_device_entry_from_ast(device_id, ast_meta)
|
||
if entry:
|
||
self.device_type_registry[device_id] = entry
|
||
|
||
for resource_id, ast_meta in ast_resources.items():
|
||
entry = self._build_resource_entry_from_ast(resource_id, ast_meta)
|
||
if entry:
|
||
self.resource_type_registry[resource_id] = entry
|
||
|
||
build_elapsed = _time.perf_counter() - build_t0
|
||
logger.info(f"[UniLab Registry] entry 构建耗时: {build_elapsed:.2f}s")
|
||
|
||
unified_cache["_build_results"] = {
|
||
"devices": {k: v for k, v in self.device_type_registry.items() if k in ast_devices},
|
||
"resources": {k: v for k, v in self.resource_type_registry.items() if k in ast_resources},
|
||
}
|
||
|
||
# upload 模式下,利用线程池并行 import pylabrobot 资源并生成 config_info
|
||
if upload_registry:
|
||
self._populate_resource_config_info(config_cache=unified_cache)
|
||
|
||
# 统一保存一次
|
||
self._save_config_cache(unified_cache)
|
||
|
||
ast_device_count = len(ast_devices)
|
||
ast_resource_count = len(ast_resources)
|
||
scan_elapsed = _time.perf_counter() - scan_t0
|
||
if ast_device_count > 0 or ast_resource_count > 0:
|
||
logger.info(
|
||
f"[UniLab Registry] AST 扫描完成: {ast_device_count} 设备, "
|
||
f"{ast_resource_count} 资源 (耗时 {scan_elapsed:.2f}s)"
|
||
)
|
||
|
||
if own_executor:
|
||
self._startup_executor.shutdown(wait=False)
|
||
self._startup_executor = None
|
||
|
||
# ------------------------------------------------------------------
|
||
# 类型辅助 (共享, 去重后的单一实现)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _replace_type_with_class(self, type_name: str, device_id: str, field_name: str) -> Any:
|
||
"""将类型名称替换为实际的 ROS 消息类对象(带缓存)"""
|
||
if not type_name or type_name == "":
|
||
return type_name
|
||
|
||
cached = self._type_resolve_cache.get(type_name)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
result = self._resolve_type_uncached(type_name, device_id, field_name)
|
||
self._type_resolve_cache[type_name] = result
|
||
return result
|
||
|
||
def _resolve_type_uncached(self, type_name: str, device_id: str, field_name: str) -> Any:
|
||
"""实际的类型解析逻辑(无缓存)"""
|
||
# 泛型类型映射
|
||
if "[" in type_name:
|
||
generic_mapping = {
|
||
"List[int]": "Int64MultiArray",
|
||
"list[int]": "Int64MultiArray",
|
||
"List[float]": "Float64MultiArray",
|
||
"list[float]": "Float64MultiArray",
|
||
"List[bool]": "Int8MultiArray",
|
||
"list[bool]": "Int8MultiArray",
|
||
}
|
||
mapped = generic_mapping.get(type_name)
|
||
if mapped:
|
||
cls = msg_converter_manager.search_class(mapped)
|
||
if cls:
|
||
return cls
|
||
logger.debug(
|
||
f"[Registry] 设备 {device_id} 的 {field_name} "
|
||
f"泛型类型 '{type_name}' 映射为 String"
|
||
)
|
||
return String
|
||
|
||
convert_manager = {
|
||
"str": "String",
|
||
"bool": "Bool",
|
||
"int": "Int64",
|
||
"float": "Float64",
|
||
}
|
||
type_name = convert_manager.get(type_name, type_name)
|
||
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.trace(
|
||
f"[Registry] 类型 '{type_name}' 非 ROS2 消息类型 (设备 {device_id} {field_name}),映射为 String"
|
||
)
|
||
return String
|
||
|
||
# ---- 类型字符串 -> JSON Schema type ----
|
||
# (常量和工具函数已移至 unilabos.registry.utils)
|
||
|
||
def _generate_schema_from_info(
|
||
self, param_name: str, param_type: Union[str, Tuple[str]], param_default: Any,
|
||
import_map: Optional[Dict[str, str]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""根据参数信息生成 JSON Schema。
|
||
支持复杂类型字符串如 'Optional[Dict[str, Any]]'、'List[int]' 等。
|
||
当提供 import_map 时,可解析 TypedDict 等自定义类型。"""
|
||
|
||
prop_schema: Dict[str, Any] = {}
|
||
|
||
if isinstance(param_type, str) and ("[" in param_type or "|" in param_type):
|
||
# 复杂泛型 — ast.parse 解析结构,递归生成 schema
|
||
node = parse_type_node(param_type)
|
||
if node is not None:
|
||
prop_schema = type_node_to_schema(node, import_map)
|
||
# slot 标记 fallback(正常不应走到这里,上层会拦截)
|
||
if "$slot" in prop_schema:
|
||
prop_schema = {"type": "object"}
|
||
else:
|
||
prop_schema["type"] = "string"
|
||
elif isinstance(param_type, str):
|
||
# 简单类型名,但可能是 import_map 中的自定义类型
|
||
json_type = SIMPLE_TYPE_MAP.get(param_type.lower())
|
||
if json_type:
|
||
prop_schema["type"] = json_type
|
||
elif ":" in param_type:
|
||
type_obj = resolve_type_object(param_type)
|
||
if type_obj is not None:
|
||
prop_schema = type_to_schema(type_obj)
|
||
else:
|
||
prop_schema["type"] = "object"
|
||
elif import_map and param_type in import_map:
|
||
type_obj = resolve_type_object(import_map[param_type])
|
||
if type_obj is not None:
|
||
prop_schema = type_to_schema(type_obj)
|
||
else:
|
||
prop_schema["type"] = "object"
|
||
else:
|
||
json_type = get_json_schema_type(param_type)
|
||
if json_type == "string" and param_type and param_type.lower() not in SIMPLE_TYPE_MAP:
|
||
prop_schema["type"] = "object"
|
||
else:
|
||
prop_schema["type"] = json_type
|
||
elif isinstance(param_type, tuple):
|
||
if len(param_type) == 2:
|
||
outer_type, inner_type = param_type
|
||
outer_json_type = get_json_schema_type(outer_type)
|
||
prop_schema["type"] = outer_json_type
|
||
# Any 值类型不加 additionalProperties/items (等同于无约束)
|
||
if isinstance(inner_type, str) and inner_type in ("Any", "None", "Unknown"):
|
||
pass
|
||
else:
|
||
inner_json_type = get_json_schema_type(inner_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:
|
||
prop_schema["type"] = get_json_schema_type(param_type)
|
||
|
||
if param_default is not None:
|
||
prop_schema["default"] = param_default
|
||
|
||
return prop_schema
|
||
|
||
def _generate_unilab_json_command_schema(
|
||
self, method_args: list, docstring: Optional[str] = None,
|
||
import_map: Optional[Dict[str, str]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""根据方法参数和 docstring 生成 UniLabJsonCommand schema"""
|
||
doc_info = parse_docstring(docstring)
|
||
param_descs = doc_info.get("params", {})
|
||
|
||
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)
|
||
|
||
is_slot, is_list_slot = detect_slot_type(param_type)
|
||
if is_slot == "ResourceSlot":
|
||
if is_list_slot:
|
||
schema["properties"][param_name] = {
|
||
"items": ros_message_to_json_schema(Resource, param_name),
|
||
"type": "array",
|
||
}
|
||
else:
|
||
schema["properties"][param_name] = ros_message_to_json_schema(
|
||
Resource, param_name
|
||
)
|
||
elif is_slot == "DeviceSlot":
|
||
schema["properties"][param_name] = {"type": "string", "description": "device reference"}
|
||
else:
|
||
schema["properties"][param_name] = self._generate_schema_from_info(
|
||
param_name, param_type, param_default, import_map=import_map
|
||
)
|
||
|
||
if param_name in param_descs:
|
||
schema["properties"][param_name]["description"] = param_descs[param_name]
|
||
|
||
if param_required:
|
||
schema["required"].append(param_name)
|
||
|
||
return schema
|
||
|
||
def _generate_status_types_schema(self, status_methods: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""根据 status 方法信息生成 status_types schema"""
|
||
status_schema: Dict[str, Any] = {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": [],
|
||
}
|
||
for status_name, status_info in status_methods.items():
|
||
return_type = status_info.get("return_type", "str")
|
||
status_schema["properties"][status_name] = self._generate_schema_from_info(
|
||
status_name, return_type, None
|
||
)
|
||
status_schema["required"].append(status_name)
|
||
return status_schema
|
||
|
||
# ------------------------------------------------------------------
|
||
# 方法签名分析 -- 委托给 ImportManager
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _analyze_method_signature(method) -> Dict[str, Any]:
|
||
"""分析方法签名,提取参数信息"""
|
||
from unilabos.utils.import_manager import default_manager
|
||
try:
|
||
return default_manager._analyze_method_signature(method)
|
||
except (ValueError, TypeError):
|
||
return {"args": [], "is_async": inspect.iscoroutinefunction(method)}
|
||
|
||
@staticmethod
|
||
def _get_return_type_from_method(method) -> str:
|
||
"""获取方法的返回类型字符串"""
|
||
from unilabos.utils.import_manager import default_manager
|
||
return default_manager._get_return_type_from_method(method)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 动态类信息提取 (import-based)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _extract_class_info(self, cls: type) -> Dict[str, Any]:
|
||
"""
|
||
从类中提取 init 参数、状态方法和动作方法信息。
|
||
"""
|
||
result = {
|
||
"class_name": cls.__name__,
|
||
"init_params": self._analyze_method_signature(cls.__init__)["args"],
|
||
"status_methods": {},
|
||
"action_methods": {},
|
||
"explicit_actions": {},
|
||
"decorated_no_type_actions": {},
|
||
}
|
||
|
||
for name, method in cls.__dict__.items():
|
||
if name.startswith("_"):
|
||
continue
|
||
|
||
# property => status
|
||
if isinstance(method, property):
|
||
return_type = self._get_return_type_from_method(method.fget) if method.fget else "Any"
|
||
status_entry = {
|
||
"name": name,
|
||
"return_type": return_type,
|
||
}
|
||
if method.fget:
|
||
tc = get_topic_config(method.fget)
|
||
if tc:
|
||
status_entry["topic_config"] = tc
|
||
result["status_methods"][name] = status_entry
|
||
|
||
if method.fset:
|
||
setter_info = self._analyze_method_signature(method.fset)
|
||
action_meta = get_action_meta(method.fset)
|
||
if action_meta and action_meta.get("action_type") is not None:
|
||
result["explicit_actions"][name] = {
|
||
"method_info": setter_info,
|
||
"action_meta": action_meta,
|
||
}
|
||
continue
|
||
|
||
if not callable(method):
|
||
continue
|
||
|
||
if is_not_action(method):
|
||
continue
|
||
|
||
# @topic_config 装饰的非 property 方法视为状态方法,不作为 action
|
||
tc = get_topic_config(method)
|
||
if tc:
|
||
return_type = self._get_return_type_from_method(method)
|
||
prop_name = name[4:] if name.startswith("get_") else name
|
||
result["status_methods"][prop_name] = {
|
||
"name": prop_name,
|
||
"return_type": return_type,
|
||
"topic_config": tc,
|
||
}
|
||
continue
|
||
|
||
method_info = self._analyze_method_signature(method)
|
||
action_meta = get_action_meta(method)
|
||
|
||
if action_meta:
|
||
action_type = action_meta.get("action_type")
|
||
if action_type is not None:
|
||
result["explicit_actions"][name] = {
|
||
"method_info": method_info,
|
||
"action_meta": action_meta,
|
||
}
|
||
else:
|
||
result["decorated_no_type_actions"][name] = {
|
||
"method_info": method_info,
|
||
"action_meta": action_meta,
|
||
}
|
||
elif has_action_decorator(method):
|
||
result["explicit_actions"][name] = {
|
||
"method_info": method_info,
|
||
"action_meta": action_meta or {},
|
||
}
|
||
else:
|
||
result["action_methods"][name] = method_info
|
||
|
||
return result
|
||
|
||
# ------------------------------------------------------------------
|
||
# 内置动作
|
||
# ------------------------------------------------------------------
|
||
|
||
def _add_builtin_actions(self, device_config: Dict[str, Any], device_id: str):
|
||
"""为设备添加内置的驱动命令动作(运行时需要,上报注册表时会过滤掉)"""
|
||
str_single_input = self._replace_type_with_class("StrSingleInput", device_id, "内置动作")
|
||
for additional_action in ["_execute_driver_command", "_execute_driver_command_async"]:
|
||
try:
|
||
goal_default = ROS2MessageInstance(str_single_input.Goal()).get_python_dict()
|
||
except Exception:
|
||
goal_default = {"string": ""}
|
||
|
||
device_config["class"]["action_value_mappings"][additional_action] = {
|
||
"type": str_single_input,
|
||
"goal": {"string": "string"},
|
||
"feedback": {},
|
||
"result": {},
|
||
"schema": ros_action_to_json_schema(str_single_input),
|
||
"goal_default": goal_default,
|
||
"handles": {},
|
||
}
|
||
|
||
# ------------------------------------------------------------------
|
||
# AST-based 注册表条目构建
|
||
# ------------------------------------------------------------------
|
||
|
||
def _build_device_entry_from_ast(self, device_id: str, ast_meta: dict) -> Dict[str, Any]:
|
||
"""
|
||
Build a device registry entry from AST-scanned metadata.
|
||
Uses only string types -- no module imports required (except for TypedDict resolution).
|
||
"""
|
||
module_str = ast_meta.get("module", "")
|
||
file_path = ast_meta.get("file_path", "")
|
||
imap = ast_meta.get("import_map") or {}
|
||
|
||
# --- status_types (string version) ---
|
||
status_types_str: Dict[str, str] = {}
|
||
for name, info in ast_meta.get("status_properties", {}).items():
|
||
ret_type = info.get("return_type", "str")
|
||
if not ret_type or ret_type in ("Any", "None", "Unknown", ""):
|
||
ret_type = "String"
|
||
# 归一化泛型容器类型: Dict[str, Any] → dict, List[int] → list 等
|
||
elif "[" in ret_type:
|
||
base = ret_type.split("[", 1)[0].strip()
|
||
base_lower = base.lower()
|
||
if base_lower in ("dict", "mapping", "ordereddict"):
|
||
ret_type = "dict"
|
||
elif base_lower in ("list", "tuple", "set", "sequence", "iterable"):
|
||
ret_type = "list"
|
||
elif base_lower == "optional":
|
||
# Optional[X] → 取内部类型再归一化
|
||
inner = ret_type.split("[", 1)[1].rsplit("]", 1)[0].strip()
|
||
inner_lower = inner.lower()
|
||
if inner_lower in ("dict", "mapping"):
|
||
ret_type = "dict"
|
||
elif inner_lower in ("list", "tuple", "set"):
|
||
ret_type = "list"
|
||
else:
|
||
ret_type = inner
|
||
status_types_str[name] = ret_type
|
||
status_types_str = dict(sorted(status_types_str.items()))
|
||
|
||
# --- action_value_mappings ---
|
||
action_value_mappings: Dict[str, Any] = {}
|
||
|
||
def _build_json_command_entry(method_name, method_info, action_args=None):
|
||
"""构建 UniLabJsonCommand 类型的 action entry"""
|
||
is_async = method_info.get("is_async", False)
|
||
type_str = "UniLabJsonCommandAsync" if is_async else "UniLabJsonCommand"
|
||
params = method_info.get("params", [])
|
||
method_doc = method_info.get("docstring")
|
||
goal_schema = self._generate_schema_from_ast_params(params, method_name, method_doc, imap)
|
||
|
||
if action_args is not None:
|
||
action_name = action_args.get("action_name", method_name)
|
||
if action_args.get("auto_prefix"):
|
||
action_name = f"auto-{action_name}"
|
||
else:
|
||
action_name = f"auto-{method_name}"
|
||
|
||
# Source C: 从 schema 生成类型默认值
|
||
goal_default = JSONSchemaMessageInstance.generate_default_from_schema(goal_schema)
|
||
# Source B: method param 显式 default 覆盖 Source C
|
||
for p in params:
|
||
if p.get("default") is not None:
|
||
goal_default[p["name"]] = p["default"]
|
||
# goal 为 identity mapping {param_name: param_name}, 默认值只放在 goal_default
|
||
goal = {p["name"]: p["name"] for p in params}
|
||
|
||
# @action 中的显式 goal/goal_default 覆盖
|
||
goal_override = dict((action_args or {}).get("goal", {}))
|
||
goal_default_override = dict((action_args or {}).get("goal_default", {}))
|
||
if goal_override:
|
||
override_values = set(goal_override.values())
|
||
goal = {k: v for k, v in goal.items() if not (k == v and v in override_values)}
|
||
goal.update(goal_override)
|
||
goal_default.update(goal_default_override)
|
||
|
||
# action handles: 从 @action(handles=[...]) 提取并转换为标准格式
|
||
raw_handles = (action_args or {}).get("handles")
|
||
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
|
||
|
||
# placeholder_keys: 优先用装饰器显式配置,否则从参数类型检测
|
||
pk = (action_args or {}).get("placeholder_keys") or detect_placeholder_keys(params)
|
||
|
||
# 从方法返回值类型生成 result schema
|
||
result_schema = None
|
||
ret_type_str = method_info.get("return_type", "")
|
||
if ret_type_str and ret_type_str not in ("None", "Any", ""):
|
||
result_schema = self._generate_schema_from_info(
|
||
"result", ret_type_str, None, imap
|
||
)
|
||
|
||
entry = {
|
||
"type": type_str,
|
||
"goal": goal,
|
||
"feedback": (action_args or {}).get("feedback") or {},
|
||
"result": (action_args or {}).get("result") or {},
|
||
"schema": wrap_action_schema(goal_schema, action_name, result_schema=result_schema),
|
||
"goal_default": goal_default,
|
||
"handles": handles,
|
||
"placeholder_keys": pk,
|
||
}
|
||
if (action_args or {}).get("always_free") or method_info.get("always_free"):
|
||
entry["always_free"] = True
|
||
return action_name, entry
|
||
|
||
# 1) auto- actions
|
||
for method_name, method_info in ast_meta.get("auto_methods", {}).items():
|
||
action_name, action_entry = _build_json_command_entry(method_name, method_info)
|
||
action_value_mappings[action_name] = action_entry
|
||
|
||
# 2) @action() without action_type
|
||
for method_name, method_info in ast_meta.get("actions", {}).items():
|
||
action_args = method_info.get("action_args", {})
|
||
if action_args.get("action_type"):
|
||
continue
|
||
action_name, action_entry = _build_json_command_entry(method_name, method_info, action_args)
|
||
action_value_mappings[action_name] = action_entry
|
||
|
||
# 3) @action(action_type=X)
|
||
for method_name, method_info in ast_meta.get("actions", {}).items():
|
||
action_args = method_info.get("action_args", {})
|
||
action_type = action_args.get("action_type")
|
||
if not action_type:
|
||
continue
|
||
|
||
action_name = action_args.get("action_name", method_name)
|
||
if action_args.get("auto_prefix"):
|
||
action_name = f"auto-{action_name}"
|
||
|
||
raw_handles = action_args.get("handles")
|
||
handles = normalize_ast_action_handles(raw_handles) if isinstance(raw_handles, list) else (raw_handles or {})
|
||
|
||
method_params = method_info.get("params", [])
|
||
|
||
# goal/feedback/result: 字段映射
|
||
# parent=True 时直接通过 import class + MRO 获取; 否则从 AST 方法参数获取, 最后从 ROS2 Goal 获取
|
||
# feedback/result 从 ROS2 获取; 默认 identity mapping {k: k}, 再用 @action 参数 update
|
||
goal_override = dict(action_args.get("goal", {}))
|
||
feedback_override = dict(action_args.get("feedback", {}))
|
||
result_override = dict(action_args.get("result", {}))
|
||
goal_default_override = dict(action_args.get("goal_default", {}))
|
||
|
||
if action_args.get("parent"):
|
||
# @action(parent=True): 直接通过 import class + MRO 获取父类方法签名
|
||
goal = resolve_method_params_via_import(module_str, method_name)
|
||
else:
|
||
# 从 AST 方法参数构建 goal identity mapping
|
||
real_params = [p for p in method_params if p["name"] not in ("self", "cls")]
|
||
goal = {p["name"]: p["name"] for p in real_params}
|
||
|
||
feedback = {}
|
||
result = {}
|
||
schema = {}
|
||
goal_default = {}
|
||
|
||
# 尝试 import ROS2 action type 获取 feedback/result/schema/goal_default, 以及 goal fallback
|
||
if ":" not in action_type:
|
||
action_type = imap.get(action_type, action_type)
|
||
action_type_obj = resolve_type_object(action_type) if ":" in action_type else None
|
||
if action_type_obj is None:
|
||
logger.warning(
|
||
f"[AST] device action '{action_name}': resolve_type_object('{action_type}') returned None"
|
||
)
|
||
if action_type_obj is not None:
|
||
# 始终从 ROS2 Goal 获取字段作为基础, 再用方法参数覆盖
|
||
try:
|
||
if hasattr(action_type_obj, "Goal"):
|
||
goal_fields = action_type_obj.Goal.get_fields_and_field_types()
|
||
ros2_goal = {k: k for k in goal_fields}
|
||
ros2_goal.update(goal)
|
||
goal = ros2_goal
|
||
except Exception as e:
|
||
logger.debug(f"[AST] device action '{action_name}': Goal enrichment from ROS2 failed: {e}")
|
||
try:
|
||
if hasattr(action_type_obj, "Feedback"):
|
||
fb_fields = action_type_obj.Feedback.get_fields_and_field_types()
|
||
feedback = {k: k for k in fb_fields}
|
||
except Exception as e:
|
||
logger.debug(f"[AST] device action '{action_name}': Feedback enrichment failed: {e}")
|
||
try:
|
||
if hasattr(action_type_obj, "Result"):
|
||
res_fields = action_type_obj.Result.get_fields_and_field_types()
|
||
result = {k: k for k in res_fields}
|
||
except Exception as e:
|
||
logger.debug(f"[AST] device action '{action_name}': Result enrichment failed: {e}")
|
||
try:
|
||
schema = ros_action_to_json_schema(action_type_obj)
|
||
except Exception:
|
||
pass
|
||
# 直接从 ROS2 Goal 实例获取默认值 (msgcenterpy)
|
||
try:
|
||
goal_default = ROS2MessageInstance(action_type_obj.Goal()).get_python_dict()
|
||
except Exception:
|
||
pass
|
||
|
||
# 如果 ROS2 action type 未提供 result schema, 用方法返回值类型生成 fallback
|
||
if not schema.get("properties", {}).get("result"):
|
||
ret_type_str = method_info.get("return_type", "")
|
||
if ret_type_str and ret_type_str not in ("None", "Any", ""):
|
||
ret_schema = self._generate_schema_from_info(
|
||
"result", ret_type_str, None, imap
|
||
)
|
||
if ret_schema:
|
||
schema.setdefault("properties", {})["result"] = ret_schema
|
||
|
||
# @action 中的显式 goal/feedback/result/goal_default 覆盖默认值
|
||
# 移除被 override 取代的 identity 条目 (如 {source: source} 被 {sources: source} 取代)
|
||
if goal_override:
|
||
override_values = set(goal_override.values())
|
||
goal = {k: v for k, v in goal.items() if not (k == v and v in override_values)}
|
||
goal.update(goal_override)
|
||
feedback.update(feedback_override)
|
||
result.update(result_override)
|
||
goal_default.update(goal_default_override)
|
||
|
||
action_entry = {
|
||
"type": action_type.split(":")[-1],
|
||
"goal": goal,
|
||
"feedback": feedback,
|
||
"result": result,
|
||
"schema": schema,
|
||
"goal_default": goal_default,
|
||
"handles": handles,
|
||
"placeholder_keys": action_args.get("placeholder_keys") or detect_placeholder_keys(method_params),
|
||
}
|
||
if action_args.get("always_free") or method_info.get("always_free"):
|
||
action_entry["always_free"] = True
|
||
action_value_mappings[action_name] = action_entry
|
||
|
||
action_value_mappings = dict(sorted(action_value_mappings.items()))
|
||
|
||
# --- init_param_schema = { config: <init_params>, data: <status_types> } ---
|
||
init_params = ast_meta.get("init_params", [])
|
||
config_schema = self._generate_schema_from_ast_params(init_params, "__init__", import_map=imap)
|
||
data_schema = self._generate_status_schema_from_ast(
|
||
ast_meta.get("status_properties", {}), imap
|
||
)
|
||
init_schema: Dict[str, Any] = {
|
||
"config": config_schema,
|
||
"data": data_schema,
|
||
}
|
||
|
||
# --- handles ---
|
||
handles_raw = ast_meta.get("handles", [])
|
||
handles = normalize_ast_handles(handles_raw)
|
||
|
||
entry: Dict[str, Any] = {
|
||
"category": ast_meta.get("category", []),
|
||
"class": {
|
||
"module": module_str,
|
||
"status_types": status_types_str,
|
||
"action_value_mappings": action_value_mappings,
|
||
"type": ast_meta.get("device_type", "python"),
|
||
},
|
||
"config_info": [],
|
||
"description": ast_meta.get("description", ""),
|
||
"handles": handles,
|
||
"icon": ast_meta.get("icon", ""),
|
||
"init_param_schema": init_schema,
|
||
"version": ast_meta.get("version", "1.0.0"),
|
||
"registry_type": "device",
|
||
"file_path": file_path,
|
||
}
|
||
model = ast_meta.get("model")
|
||
if model is not None:
|
||
entry["model"] = model
|
||
hardware_interface = ast_meta.get("hardware_interface")
|
||
if hardware_interface is not None:
|
||
# AST 解析 HardwareInterface(...) 得到 {"_call": "...", "name": ..., "read": ..., "write": ...}
|
||
# 归一化为 YAML 格式,去掉 _call
|
||
if isinstance(hardware_interface, dict) and "_call" in hardware_interface:
|
||
hardware_interface = {k: v for k, v in hardware_interface.items() if k != "_call"}
|
||
entry["class"]["hardware_interface"] = hardware_interface
|
||
return entry
|
||
|
||
def _generate_schema_from_ast_params(
|
||
self, params: list, method_name: str, docstring: Optional[str] = None,
|
||
import_map: Optional[Dict[str, str]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Generate JSON Schema from AST-extracted parameter list."""
|
||
doc_info = parse_docstring(docstring)
|
||
param_descs = doc_info.get("params", {})
|
||
|
||
schema: Dict[str, Any] = {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": [],
|
||
}
|
||
for p in params:
|
||
pname = p.get("name", "")
|
||
ptype = p.get("type", "")
|
||
pdefault = p.get("default")
|
||
prequired = p.get("required", True)
|
||
|
||
# --- 检测 ResourceSlot / DeviceSlot (兼容 runtime 和 AST 两种格式) ---
|
||
is_slot, is_list_slot = detect_slot_type(ptype)
|
||
if is_slot == "ResourceSlot":
|
||
if is_list_slot:
|
||
schema["properties"][pname] = {
|
||
"items": ros_message_to_json_schema(Resource, pname),
|
||
"type": "array",
|
||
}
|
||
else:
|
||
schema["properties"][pname] = ros_message_to_json_schema(Resource, pname)
|
||
elif is_slot == "DeviceSlot":
|
||
schema["properties"][pname] = {"type": "string", "description": "device reference"}
|
||
else:
|
||
schema["properties"][pname] = self._generate_schema_from_info(
|
||
pname, ptype, pdefault, import_map
|
||
)
|
||
|
||
if pname in param_descs:
|
||
schema["properties"][pname]["description"] = param_descs[pname]
|
||
|
||
if prequired:
|
||
schema["required"].append(pname)
|
||
|
||
return schema
|
||
|
||
def _generate_status_schema_from_ast(
|
||
self, status_properties: Dict[str, Any],
|
||
import_map: Optional[Dict[str, str]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Generate status_types schema from AST-extracted status properties."""
|
||
schema: Dict[str, Any] = {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": [],
|
||
}
|
||
for name, info in status_properties.items():
|
||
ret_type = info.get("return_type", "str")
|
||
schema["properties"][name] = self._generate_schema_from_info(
|
||
name, ret_type, None, import_map
|
||
)
|
||
schema["required"].append(name)
|
||
return schema
|
||
|
||
def _build_resource_entry_from_ast(self, resource_id: str, ast_meta: dict) -> Dict[str, Any]:
|
||
"""Build a resource registry entry from AST-scanned metadata."""
|
||
module_str = ast_meta.get("module", "")
|
||
file_path = ast_meta.get("file_path", "")
|
||
|
||
handles_raw = ast_meta.get("handles", [])
|
||
handles = normalize_ast_handles(handles_raw)
|
||
|
||
entry: Dict[str, Any] = {
|
||
"category": ast_meta.get("category", []),
|
||
"class": {
|
||
"module": module_str,
|
||
"type": ast_meta.get("class_type", "python"),
|
||
},
|
||
"config_info": [],
|
||
"description": ast_meta.get("description", ""),
|
||
"handles": handles,
|
||
"icon": ast_meta.get("icon", ""),
|
||
"init_param_schema": {},
|
||
"version": ast_meta.get("version", "1.0.0"),
|
||
"registry_type": "resource",
|
||
"file_path": file_path,
|
||
}
|
||
|
||
if ast_meta.get("model"):
|
||
entry["model"] = ast_meta["model"]
|
||
|
||
return entry
|
||
|
||
# ------------------------------------------------------------------
|
||
# 定向 AST 扫描(供 complete_registry Case 1 使用)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _ast_scan_module(self, module_str: str) -> Optional[Dict[str, Any]]:
|
||
"""对单个 module_str 做定向 AST 扫描,返回 ast_meta 或 None。
|
||
|
||
用于 complete_registry 模式下 YAML 中存在但 AST 全量扫描未覆盖的设备/资源。
|
||
仅做文件定位 + AST 解析,不实例化类。
|
||
"""
|
||
from unilabos.registry.ast_registry_scanner import _parse_file
|
||
|
||
mod_part = module_str.split(":")[0]
|
||
try:
|
||
mod = importlib.import_module(mod_part)
|
||
src_file = Path(inspect.getfile(mod))
|
||
except Exception:
|
||
return None
|
||
|
||
python_path = Path(__file__).resolve().parent.parent.parent
|
||
try:
|
||
devs, ress = _parse_file(src_file, python_path)
|
||
except Exception:
|
||
return None
|
||
|
||
for d in devs:
|
||
if d.get("module") == module_str:
|
||
return d
|
||
for r in ress:
|
||
if r.get("module") == module_str:
|
||
return r
|
||
return None
|
||
|
||
# ------------------------------------------------------------------
|
||
# config_info 缓存 (pickle 格式,比 JSON 快 ~10x,debug 模式下差异更大)
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _get_config_cache_path() -> Optional[Path]:
|
||
if BasicConfig.working_dir:
|
||
return Path(BasicConfig.working_dir) / "registry_cache.pkl"
|
||
return None
|
||
|
||
_CACHE_VERSION = 3
|
||
|
||
def _load_config_cache(self) -> dict:
|
||
import pickle
|
||
cache_path = self._get_config_cache_path()
|
||
if cache_path is None or not cache_path.is_file():
|
||
return {}
|
||
try:
|
||
data = pickle.loads(cache_path.read_bytes())
|
||
if not isinstance(data, dict) or data.get("_version") != self._CACHE_VERSION:
|
||
return {}
|
||
return data
|
||
except Exception:
|
||
return {}
|
||
|
||
def _save_config_cache(self, cache: dict) -> None:
|
||
import pickle
|
||
cache_path = self._get_config_cache_path()
|
||
if cache_path is None:
|
||
return
|
||
try:
|
||
cache["_version"] = self._CACHE_VERSION
|
||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp = cache_path.with_suffix(".tmp")
|
||
tmp.write_bytes(pickle.dumps(cache, protocol=pickle.HIGHEST_PROTOCOL))
|
||
tmp.replace(cache_path)
|
||
except Exception as e:
|
||
logger.debug(f"[UniLab Registry] 缓存保存失败: {e}")
|
||
|
||
@staticmethod
|
||
def _module_source_hash(module_str: str) -> Optional[str]:
|
||
"""Fast MD5 of the source file backing *module_str*. Results are
|
||
cached for the process lifetime so the same file is never read twice."""
|
||
if module_str in _module_hash_cache:
|
||
return _module_hash_cache[module_str]
|
||
|
||
import hashlib
|
||
import importlib.util
|
||
mod_part = module_str.split(":")[0] if ":" in module_str else module_str
|
||
result = None
|
||
try:
|
||
spec = importlib.util.find_spec(mod_part)
|
||
if spec and spec.origin and os.path.isfile(spec.origin):
|
||
result = hashlib.md5(open(spec.origin, "rb").read()).hexdigest()
|
||
except Exception:
|
||
pass
|
||
_module_hash_cache[module_str] = result
|
||
return result
|
||
|
||
def _populate_resource_config_info(self, config_cache: Optional[dict] = None):
|
||
"""
|
||
利用线程池并行 import pylabrobot 资源类,生成 config_info。
|
||
仅在 upload_registry=True 时调用。
|
||
|
||
启用缓存:以 module_str 为 key,记录源文件 MD5。若源文件未变则
|
||
直接复用上次的 config_info,跳过 import + 实例化 + dump。
|
||
|
||
Args:
|
||
config_cache: 共享的缓存 dict。未提供时自行加载/保存;
|
||
由 load_resource_types 传入时由调用方统一保存。
|
||
"""
|
||
import time as _time
|
||
|
||
executor = self._startup_executor
|
||
if executor is None:
|
||
return
|
||
|
||
# 筛选需要 import 的 pylabrobot 资源(跳过已有 config_info 的缓存条目)
|
||
pylabrobot_entries = {
|
||
rid: entry
|
||
for rid, entry in self.resource_type_registry.items()
|
||
if entry.get("class", {}).get("type") == "pylabrobot"
|
||
and entry.get("class", {}).get("module")
|
||
and not entry.get("config_info")
|
||
}
|
||
if not pylabrobot_entries:
|
||
return
|
||
|
||
t0 = _time.perf_counter()
|
||
own_cache = config_cache is None
|
||
if own_cache:
|
||
config_cache = self._load_config_cache()
|
||
cache_hits = 0
|
||
cache_misses = 0
|
||
|
||
def _import_and_dump(resource_id: str, module_str: str):
|
||
"""Import class, create instance, dump tree. Returns (rid, config_info)."""
|
||
try:
|
||
res_class = import_class(module_str)
|
||
if callable(res_class) and not isinstance(res_class, type):
|
||
res_instance = res_class(res_class.__name__)
|
||
tree_set = ResourceTreeSet.from_plr_resources([res_instance], known_newly_created=True, old_size=True)
|
||
dumped = tree_set.dump(old_position=True)
|
||
return resource_id, dumped[0] if dumped else []
|
||
except Exception as e:
|
||
logger.warning(f"[UniLab Registry] 资源 {resource_id} config_info 生成失败: {e}")
|
||
return resource_id, []
|
||
|
||
# Separate into cache-hit vs cache-miss
|
||
need_generate: dict = {} # rid -> module_str
|
||
for rid, entry in pylabrobot_entries.items():
|
||
module_str = entry["class"]["module"]
|
||
cached = config_cache.get(module_str)
|
||
if cached and isinstance(cached, dict) and "config_info" in cached:
|
||
src_hash = self._module_source_hash(module_str)
|
||
if src_hash is not None and cached.get("src_hash") == src_hash:
|
||
self.resource_type_registry[rid]["config_info"] = cached["config_info"]
|
||
cache_hits += 1
|
||
continue
|
||
need_generate[rid] = module_str
|
||
|
||
cache_misses = len(need_generate)
|
||
|
||
if need_generate:
|
||
future_to_rid = {
|
||
executor.submit(_import_and_dump, rid, mod): rid
|
||
for rid, mod in need_generate.items()
|
||
}
|
||
for future in as_completed(future_to_rid):
|
||
try:
|
||
resource_id, config_info = future.result()
|
||
self.resource_type_registry[resource_id]["config_info"] = config_info
|
||
module_str = need_generate[resource_id]
|
||
src_hash = self._module_source_hash(module_str)
|
||
config_cache[module_str] = {
|
||
"src_hash": src_hash,
|
||
"config_info": config_info,
|
||
}
|
||
except Exception as e:
|
||
rid = future_to_rid[future]
|
||
logger.warning(f"[UniLab Registry] 资源 {rid} config_info 线程异常: {e}")
|
||
|
||
if own_cache:
|
||
self._save_config_cache(config_cache)
|
||
|
||
elapsed = _time.perf_counter() - t0
|
||
total = cache_hits + cache_misses
|
||
logger.info(
|
||
f"[UniLab Registry] config_info 缓存统计: "
|
||
f"{cache_hits}/{total} 命中, {cache_misses} 重新生成 "
|
||
f"(耗时 {elapsed:.2f}s)"
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Verify & Resolve (实际 import 验证)
|
||
# ------------------------------------------------------------------
|
||
|
||
def verify_and_resolve_registry(self):
|
||
"""
|
||
对 AST 扫描得到的注册表执行实际 import 验证(使用共享线程池并行)。
|
||
"""
|
||
errors = []
|
||
import_success_count = 0
|
||
resolved_count = 0
|
||
total_items = len(self.device_type_registry) + len(self.resource_type_registry)
|
||
|
||
lock = threading.Lock()
|
||
|
||
def _verify_device(device_id: str, entry: dict):
|
||
nonlocal import_success_count, resolved_count
|
||
module_str = entry.get("class", {}).get("module", "")
|
||
if not module_str or ":" not in module_str:
|
||
with lock:
|
||
import_success_count += 1
|
||
return None
|
||
|
||
try:
|
||
cls = import_class(module_str)
|
||
with lock:
|
||
import_success_count += 1
|
||
resolved_count += 1
|
||
|
||
# 尝试用动态信息增强注册表
|
||
try:
|
||
self.resolve_types_for_device(device_id, cls)
|
||
except Exception as e:
|
||
logger.debug(f"[UniLab Registry/Verify] 设备 {device_id} 类型解析失败: {e}")
|
||
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[UniLab Registry/Verify] 设备 {device_id}: "
|
||
f"导入模块 {module_str} 失败: {e}"
|
||
)
|
||
return f"device:{device_id}: {e}"
|
||
|
||
def _verify_resource(resource_id: str, entry: dict):
|
||
nonlocal import_success_count
|
||
module_str = entry.get("class", {}).get("module", "")
|
||
if not module_str or ":" not in module_str:
|
||
with lock:
|
||
import_success_count += 1
|
||
return None
|
||
|
||
try:
|
||
import_class(module_str)
|
||
with lock:
|
||
import_success_count += 1
|
||
return None
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[UniLab Registry/Verify] 资源 {resource_id}: "
|
||
f"导入模块 {module_str} 失败: {e}"
|
||
)
|
||
return f"resource:{resource_id}: {e}"
|
||
|
||
executor = self._startup_executor or ThreadPoolExecutor(max_workers=8)
|
||
try:
|
||
device_futures = {}
|
||
resource_futures = {}
|
||
|
||
for device_id, entry in list(self.device_type_registry.items()):
|
||
fut = executor.submit(_verify_device, device_id, entry)
|
||
device_futures[fut] = device_id
|
||
|
||
for resource_id, entry in list(self.resource_type_registry.items()):
|
||
fut = executor.submit(_verify_resource, resource_id, entry)
|
||
resource_futures[fut] = resource_id
|
||
|
||
for future in as_completed(device_futures):
|
||
result = future.result()
|
||
if result:
|
||
errors.append(result)
|
||
|
||
for future in as_completed(resource_futures):
|
||
result = future.result()
|
||
if result:
|
||
errors.append(result)
|
||
finally:
|
||
if self._startup_executor is None:
|
||
executor.shutdown(wait=True)
|
||
|
||
if errors:
|
||
logger.warning(
|
||
f"[UniLab Registry/Verify] 验证完成: {import_success_count}/{total_items} 成功, "
|
||
f"{len(errors)} 个错误"
|
||
)
|
||
else:
|
||
logger.info(
|
||
f"[UniLab Registry/Verify] 验证完成: {import_success_count}/{total_items} 全部通过, "
|
||
f"{resolved_count} 设备类型已解析"
|
||
)
|
||
|
||
return errors
|
||
|
||
def resolve_types_for_device(self, device_id: str, cls=None):
|
||
"""
|
||
将 AST 扫描得到的字符串类型引用替换为实际的 ROS 消息类对象。
|
||
"""
|
||
entry = self.device_type_registry.get(device_id)
|
||
if not entry:
|
||
return
|
||
|
||
class_info = entry.get("class", {})
|
||
|
||
# 解析 status_types
|
||
status_types = class_info.get("status_types", {})
|
||
resolved_status = {}
|
||
for name, type_ref in status_types.items():
|
||
if isinstance(type_ref, str):
|
||
resolved = self._replace_type_with_class(type_ref, device_id, f"状态 {name}")
|
||
if resolved:
|
||
resolved_status[name] = resolved
|
||
else:
|
||
resolved_status[name] = type_ref
|
||
else:
|
||
resolved_status[name] = type_ref
|
||
class_info["status_types"] = resolved_status
|
||
|
||
# 解析 action_value_mappings
|
||
_KEEP_AS_STRING = {"UniLabJsonCommand", "UniLabJsonCommandAsync"}
|
||
action_mappings = class_info.get("action_value_mappings", {})
|
||
for action_name, action_config in action_mappings.items():
|
||
type_ref = action_config.get("type", "")
|
||
if isinstance(type_ref, str) and type_ref and type_ref not in _KEEP_AS_STRING:
|
||
resolved = self._replace_type_with_class(type_ref, device_id, f"动作 {action_name}")
|
||
if resolved:
|
||
action_config["type"] = resolved
|
||
if not action_config.get("schema"):
|
||
try:
|
||
action_config["schema"] = ros_action_to_json_schema(resolved)
|
||
except Exception:
|
||
pass
|
||
if not action_config.get("goal_default"):
|
||
try:
|
||
action_config["goal_default"] = ROS2MessageInstance(resolved.Goal()).get_python_dict()
|
||
except Exception:
|
||
pass
|
||
|
||
# 如果提供了类,用动态信息增强
|
||
if cls is not None:
|
||
try:
|
||
dynamic_info = self._extract_class_info(cls)
|
||
|
||
for name, info in dynamic_info.get("status_methods", {}).items():
|
||
if name not in resolved_status:
|
||
ret_type = info.get("return_type", "str")
|
||
resolved = self._replace_type_with_class(ret_type, device_id, f"状态 {name}")
|
||
if resolved:
|
||
class_info["status_types"][name] = resolved
|
||
|
||
for action_name_key, action_config in action_mappings.items():
|
||
type_obj = action_config.get("type")
|
||
if isinstance(type_obj, str) and type_obj in (
|
||
"UniLabJsonCommand", "UniLabJsonCommandAsync"
|
||
):
|
||
method_name = action_name_key
|
||
if method_name.startswith("auto-"):
|
||
method_name = method_name[5:]
|
||
|
||
actual_method = getattr(cls, method_name, None)
|
||
if actual_method:
|
||
method_info = self._analyze_method_signature(actual_method)
|
||
schema = self._generate_unilab_json_command_schema(
|
||
method_info["args"],
|
||
docstring=getattr(actual_method, "__doc__", None),
|
||
)
|
||
action_config["schema"] = schema
|
||
except Exception as e:
|
||
logger.debug(f"[Registry] 设备 {device_id} 动态增强失败: {e}")
|
||
|
||
# 添加内置动作
|
||
self._add_builtin_actions(entry, device_id)
|
||
|
||
def resolve_all_types(self):
|
||
"""将所有注册表条目中的字符串类型引用替换为实际的 ROS2 消息类对象。
|
||
|
||
仅做 ROS2 消息类型查找,不 import 任何设备模块,速度快且无副作用。
|
||
"""
|
||
t0 = time.time()
|
||
for device_id in list(self.device_type_registry):
|
||
try:
|
||
self.resolve_types_for_device(device_id)
|
||
except Exception as e:
|
||
logger.debug(f"[Registry] 设备 {device_id} 类型解析失败: {e}")
|
||
logger.info(
|
||
f"[UniLab Registry] 类型解析完成: {len(self.device_type_registry)} 设备 "
|
||
f"(耗时 {time.time() - t0:.2f}s)"
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# YAML 注册表加载 (兼容旧格式)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _load_single_resource_file(
|
||
self, file: Path, complete_registry: bool
|
||
) -> Tuple[Dict[str, Any], Dict[str, Any], bool]:
|
||
"""
|
||
加载单个资源文件 (线程安全)
|
||
|
||
Returns:
|
||
(data, complete_data, is_valid): 资源数据, 完整数据, 是否有效
|
||
"""
|
||
try:
|
||
with open(file, encoding="utf-8", mode="r") as f:
|
||
data = yaml.safe_load(io.StringIO(f.read()))
|
||
except Exception as e:
|
||
logger.warning(f"[UniLab Registry] 读取资源文件失败: {file}, 错误: {e}")
|
||
return {}, {}, False
|
||
|
||
if not data:
|
||
return {}, {}, False
|
||
|
||
complete_data = {}
|
||
skip_ids = set()
|
||
for resource_id, resource_info in data.items():
|
||
if not isinstance(resource_info, dict):
|
||
continue
|
||
|
||
# AST 已有该资源 → 跳过,提示冗余
|
||
if self.resource_type_registry.get(resource_id):
|
||
logger.warning(
|
||
f"[UniLab Registry] 资源 '{resource_id}' 已由 AST 扫描注册,"
|
||
f"YAML 定义冗余,跳过 YAML 处理"
|
||
)
|
||
skip_ids.add(resource_id)
|
||
continue
|
||
|
||
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)
|
||
elif not isinstance(resource_info.get("category"), list):
|
||
resource_info["category"] = [resource_info["category"]]
|
||
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"]
|
||
resource_info["registry_type"] = "resource"
|
||
resource_info["file_path"] = str(file.absolute()).replace("\\", "/")
|
||
complete_data[resource_id] = copy.deepcopy(dict(sorted(resource_info.items())))
|
||
|
||
for rid in skip_ids:
|
||
data.pop(rid, None)
|
||
|
||
complete_data = dict(sorted(complete_data.items()))
|
||
|
||
if complete_registry:
|
||
write_data = copy.deepcopy(complete_data)
|
||
for res_id, res_cfg in write_data.items():
|
||
res_cfg.pop("file_path", None)
|
||
res_cfg.pop("registry_type", None)
|
||
try:
|
||
with open(file, "w", encoding="utf-8") as f:
|
||
yaml.dump(write_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
|
||
except Exception as e:
|
||
logger.warning(f"[UniLab Registry] 写入资源文件失败: {file}, 错误: {e}")
|
||
|
||
return data, complete_data, True
|
||
|
||
def load_resource_types(self, path: os.PathLike, upload_registry: bool, complete_registry: bool = False):
|
||
abs_path = Path(path).absolute()
|
||
resources_path = abs_path / "resources"
|
||
files = list(resources_path.rglob("*.yaml"))
|
||
logger.trace(
|
||
f"[UniLab Registry] resources: {resources_path.exists()}, total: {len(files)}"
|
||
)
|
||
|
||
if not files:
|
||
return
|
||
|
||
import hashlib as _hl
|
||
|
||
# --- YAML-level cache: per-file entries with config_info ---
|
||
config_cache = self._load_config_cache() if upload_registry else None
|
||
yaml_cache: dict = config_cache.get("_yaml_resources", {}) if config_cache else {}
|
||
yaml_cache_hits = 0
|
||
yaml_cache_misses = 0
|
||
uncached_files: list[Path] = []
|
||
yaml_file_rids: dict[str, list[str]] = {}
|
||
|
||
if complete_registry:
|
||
uncached_files = files
|
||
yaml_cache_misses = len(files)
|
||
else:
|
||
for file in files:
|
||
file_key = str(file.absolute()).replace("\\", "/")
|
||
if upload_registry and yaml_cache:
|
||
try:
|
||
yaml_md5 = _hl.md5(file.read_bytes()).hexdigest()
|
||
except OSError:
|
||
uncached_files.append(file)
|
||
yaml_cache_misses += 1
|
||
continue
|
||
cached = yaml_cache.get(file_key)
|
||
if cached and cached.get("yaml_md5") == yaml_md5:
|
||
module_hashes: dict = cached.get("module_hashes", {})
|
||
all_ok = all(
|
||
self._module_source_hash(m) == h
|
||
for m, h in module_hashes.items()
|
||
) if module_hashes else True
|
||
if all_ok and cached.get("entries"):
|
||
for rid, entry in cached["entries"].items():
|
||
self.resource_type_registry[rid] = entry
|
||
yaml_cache_hits += 1
|
||
continue
|
||
uncached_files.append(file)
|
||
yaml_cache_misses += 1
|
||
|
||
# Process uncached YAML files with thread pool
|
||
executor = self._startup_executor
|
||
future_to_file = {
|
||
executor.submit(self._load_single_resource_file, file, complete_registry): file
|
||
for file in uncached_files
|
||
}
|
||
|
||
for future in as_completed(future_to_file):
|
||
file = future_to_file[future]
|
||
try:
|
||
data, complete_data, is_valid = future.result()
|
||
if is_valid:
|
||
self.resource_type_registry.update(complete_data)
|
||
file_key = str(file.absolute()).replace("\\", "/")
|
||
yaml_file_rids[file_key] = list(complete_data.keys())
|
||
except Exception as e:
|
||
logger.warning(f"[UniLab Registry] 加载资源文件失败: {file}, 错误: {e}")
|
||
|
||
# upload 模式下,统一利用线程池为 pylabrobot 资源生成 config_info
|
||
if upload_registry:
|
||
self._populate_resource_config_info(config_cache=config_cache)
|
||
|
||
# Update YAML cache for newly processed files (entries now have config_info)
|
||
if yaml_file_rids and config_cache is not None:
|
||
for file_key, rids in yaml_file_rids.items():
|
||
entries = {}
|
||
module_hashes = {}
|
||
for rid in rids:
|
||
entry = self.resource_type_registry.get(rid)
|
||
if entry:
|
||
entries[rid] = copy.deepcopy(entry)
|
||
mod_str = entry.get("class", {}).get("module", "")
|
||
if mod_str and mod_str not in module_hashes:
|
||
src_h = self._module_source_hash(mod_str)
|
||
if src_h:
|
||
module_hashes[mod_str] = src_h
|
||
try:
|
||
yaml_md5 = _hl.md5(Path(file_key).read_bytes()).hexdigest()
|
||
except OSError:
|
||
continue
|
||
yaml_cache[file_key] = {
|
||
"yaml_md5": yaml_md5,
|
||
"module_hashes": module_hashes,
|
||
"entries": entries,
|
||
}
|
||
config_cache["_yaml_resources"] = yaml_cache
|
||
self._save_config_cache(config_cache)
|
||
|
||
total_yaml = yaml_cache_hits + yaml_cache_misses
|
||
if upload_registry and total_yaml > 0:
|
||
logger.info(
|
||
f"[UniLab Registry] YAML 资源缓存: "
|
||
f"{yaml_cache_hits}/{total_yaml} 文件命中, "
|
||
f"{yaml_cache_misses} 重新加载"
|
||
)
|
||
|
||
def _load_single_device_file(
|
||
self, file: Path, complete_registry: bool
|
||
) -> Tuple[Dict[str, Any], Dict[str, Any], bool, List[str]]:
|
||
"""
|
||
加载单个设备文件 (线程安全)
|
||
|
||
Returns:
|
||
(data, complete_data, is_valid, device_ids): 设备数据, 完整数据, 是否有效, 设备ID列表
|
||
"""
|
||
try:
|
||
with open(file, encoding="utf-8", mode="r") as f:
|
||
data = yaml.safe_load(io.StringIO(f.read()))
|
||
except Exception as e:
|
||
logger.warning(f"[UniLab Registry] 读取设备文件失败: {file}, 错误: {e}")
|
||
return {}, {}, False, []
|
||
|
||
if not data:
|
||
return {}, {}, False, []
|
||
|
||
complete_data = {}
|
||
action_str_type_mapping = {
|
||
"UniLabJsonCommand": "UniLabJsonCommand",
|
||
"UniLabJsonCommandAsync": "UniLabJsonCommandAsync",
|
||
}
|
||
status_str_type_mapping = {}
|
||
device_ids = []
|
||
|
||
skip_ids = set()
|
||
for device_id, device_config in data.items():
|
||
if not isinstance(device_config, dict):
|
||
continue
|
||
|
||
# 补全默认字段
|
||
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:
|
||
# --- AST 已有该设备 → 跳过,提示冗余 ---
|
||
if self.device_type_registry.get(device_id):
|
||
logger.warning(
|
||
f"[UniLab Registry] 设备 '{device_id}' 已由 AST 扫描注册,"
|
||
f"YAML 定义冗余,跳过 YAML 处理"
|
||
)
|
||
skip_ids.add(device_id)
|
||
continue
|
||
|
||
# --- 正常 YAML 处理 ---
|
||
if "status_types" not in device_config["class"] or device_config["class"]["status_types"] is None:
|
||
device_config["class"]["status_types"] = {}
|
||
if (
|
||
"action_value_mappings" not in device_config["class"]
|
||
or device_config["class"]["action_value_mappings"] is None
|
||
):
|
||
device_config["class"]["action_value_mappings"] = {}
|
||
|
||
enhanced_info = {}
|
||
enhanced_import_map: Dict[str, str] = {}
|
||
if complete_registry:
|
||
original_status_keys = set(device_config["class"]["status_types"].keys())
|
||
device_config["class"]["status_types"].clear()
|
||
enhanced_info = get_enhanced_class_info(device_config["class"]["module"])
|
||
if not enhanced_info.get("ast_analysis_success", False):
|
||
continue
|
||
enhanced_import_map = enhanced_info.get("import_map", {})
|
||
for st_k, st_v in enhanced_info["status_methods"].items():
|
||
if st_k in original_status_keys:
|
||
device_config["class"]["status_types"][st_k] = st_v["return_type"]
|
||
|
||
# --- status_types: 字符串 → class 映射 ---
|
||
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"
|
||
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:
|
||
old_action_configs = dict(device_config["class"]["action_value_mappings"])
|
||
|
||
device_config["class"]["action_value_mappings"] = {
|
||
k: v
|
||
for k, v in device_config["class"]["action_value_mappings"].items()
|
||
if not k.startswith("auto-")
|
||
}
|
||
for k, v in enhanced_info["action_methods"].items():
|
||
if k in device_config["class"]["action_value_mappings"]:
|
||
action_key = k
|
||
elif k.startswith("get_"):
|
||
continue
|
||
else:
|
||
action_key = f"auto-{k}"
|
||
goal_schema = self._generate_unilab_json_command_schema(
|
||
v["args"], import_map=enhanced_import_map
|
||
)
|
||
ret_type = v.get("return_type", "")
|
||
result_schema = None
|
||
if ret_type and ret_type not in ("None", "Any", ""):
|
||
result_schema = self._generate_schema_from_info(
|
||
"result", ret_type, None, import_map=enhanced_import_map
|
||
)
|
||
old_cfg = old_action_configs.get(action_key) or old_action_configs.get(f"auto-{k}", {})
|
||
new_schema = wrap_action_schema(goal_schema, action_key, result_schema=result_schema)
|
||
old_schema = old_cfg.get("schema", {})
|
||
if old_schema:
|
||
preserve_field_descriptions(new_schema, old_schema)
|
||
if "description" in old_schema:
|
||
new_schema["description"] = old_schema["description"]
|
||
new_schema.setdefault("description", "")
|
||
|
||
old_type = old_cfg.get("type", "")
|
||
entry_goal = old_cfg.get("goal", {})
|
||
entry_feedback = {}
|
||
entry_result = {}
|
||
entry_schema = new_schema
|
||
entry_goal_default = {i["name"]: i.get("default") for i in v["args"]}
|
||
|
||
if old_type and not old_type.startswith("UniLabJsonCommand"):
|
||
entry_type = old_type
|
||
try:
|
||
action_type_obj = self._replace_type_with_class(
|
||
old_type, device_id, f"动作 {action_key}"
|
||
)
|
||
except ROSMsgNotFound:
|
||
action_type_obj = None
|
||
if action_type_obj is not None and not isinstance(action_type_obj, str):
|
||
real_params = [p for p in v["args"]]
|
||
ros_goal = {p["name"]: p["name"] for p in real_params}
|
||
try:
|
||
if hasattr(action_type_obj, "Goal"):
|
||
goal_fields = action_type_obj.Goal.get_fields_and_field_types()
|
||
ros2_goal = {f: f for f in goal_fields}
|
||
ros2_goal.update(ros_goal)
|
||
entry_goal = ros2_goal
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if hasattr(action_type_obj, "Feedback"):
|
||
fb_fields = action_type_obj.Feedback.get_fields_and_field_types()
|
||
entry_feedback = {f: f for f in fb_fields}
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if hasattr(action_type_obj, "Result"):
|
||
res_fields = action_type_obj.Result.get_fields_and_field_types()
|
||
entry_result = {f: f for f in res_fields}
|
||
except Exception:
|
||
pass
|
||
try:
|
||
entry_schema = ros_action_to_json_schema(action_type_obj)
|
||
if old_schema:
|
||
preserve_field_descriptions(entry_schema, old_schema)
|
||
if "description" in old_schema:
|
||
entry_schema["description"] = old_schema["description"]
|
||
entry_schema.setdefault("description", "")
|
||
except Exception:
|
||
pass
|
||
try:
|
||
entry_goal_default = ROS2MessageInstance(
|
||
action_type_obj.Goal()
|
||
).get_python_dict()
|
||
except Exception:
|
||
entry_goal_default = old_cfg.get("goal_default", {})
|
||
else:
|
||
entry_type = "UniLabJsonCommandAsync" if v["is_async"] else "UniLabJsonCommand"
|
||
|
||
merged_pk = dict(old_cfg.get("placeholder_keys", {}))
|
||
merged_pk.update(detect_placeholder_keys(v["args"]))
|
||
|
||
entry = {
|
||
"type": entry_type,
|
||
"goal": entry_goal,
|
||
"feedback": entry_feedback,
|
||
"result": entry_result,
|
||
"schema": entry_schema,
|
||
"goal_default": entry_goal_default,
|
||
"handles": old_cfg.get("handles", []),
|
||
"placeholder_keys": merged_pk,
|
||
}
|
||
if v.get("always_free"):
|
||
entry["always_free"] = True
|
||
device_config["class"]["action_value_mappings"][action_key] = entry
|
||
|
||
device_config["init_param_schema"] = {}
|
||
init_schema = self._generate_unilab_json_command_schema(
|
||
enhanced_info["init_params"], "__init__",
|
||
import_map=enhanced_import_map,
|
||
)
|
||
device_config["init_param_schema"]["config"] = init_schema
|
||
|
||
data_schema: Dict[str, Any] = {
|
||
"type": "object",
|
||
"properties": {},
|
||
"required": [],
|
||
}
|
||
for st_name in device_config["class"]["status_types"]:
|
||
st_type_str = device_config["class"]["status_types"][st_name]
|
||
if isinstance(st_type_str, str):
|
||
data_schema["properties"][st_name] = self._generate_schema_from_info(
|
||
st_name, st_type_str, None, import_map=enhanced_import_map
|
||
)
|
||
else:
|
||
data_schema["properties"][st_name] = {"type": "string"}
|
||
data_schema["required"].append(st_name)
|
||
device_config["init_param_schema"]["data"] = data_schema
|
||
|
||
# --- action_value_mappings: 处理非 UniLabJsonCommand 类型 ---
|
||
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"]
|
||
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:
|
||
try:
|
||
action_config["goal_default"] = ROS2MessageInstance(target_type.Goal()).get_python_dict()
|
||
except Exception:
|
||
action_config["goal_default"] = {}
|
||
prev_schema = action_config.get("schema", {})
|
||
action_config["schema"] = ros_action_to_json_schema(target_type)
|
||
if prev_schema:
|
||
preserve_field_descriptions(action_config["schema"], prev_schema)
|
||
if "description" in prev_schema:
|
||
action_config["schema"]["description"] = prev_schema["description"]
|
||
action_config["schema"].setdefault("description", "")
|
||
else:
|
||
logger.warning(
|
||
f"[UniLab Registry] 设备 {device_id} 的动作 {action_name} 类型为空,跳过替换"
|
||
)
|
||
|
||
# deepcopy 保存可序列化的 complete_data(此时 type 字段仍为字符串)
|
||
device_config["file_path"] = str(file.absolute()).replace("\\", "/")
|
||
device_config["registry_type"] = "device"
|
||
complete_data[device_id] = copy.deepcopy(dict(sorted(device_config.items())))
|
||
|
||
# 之后才把 type 字符串替换为 class 对象(仅用于运行时 data)
|
||
for status_name, status_type in device_config["class"]["status_types"].items():
|
||
if status_type in status_str_type_mapping:
|
||
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():
|
||
if action_config.get("type") in action_str_type_mapping:
|
||
action_config["type"] = action_str_type_mapping[action_config["type"]]
|
||
|
||
self._add_builtin_actions(device_config, device_id)
|
||
|
||
device_ids.append(device_id)
|
||
|
||
for did in skip_ids:
|
||
data.pop(did, None)
|
||
|
||
complete_data = dict(sorted(complete_data.items()))
|
||
complete_data = copy.deepcopy(complete_data)
|
||
if complete_registry:
|
||
write_data = copy.deepcopy(complete_data)
|
||
for dev_id, dev_cfg in write_data.items():
|
||
dev_cfg.pop("file_path", None)
|
||
dev_cfg.pop("registry_type", None)
|
||
try:
|
||
with open(file, "w", encoding="utf-8") as f:
|
||
yaml.dump(write_data, f, allow_unicode=True, default_flow_style=False, Dumper=NoAliasDumper)
|
||
except Exception as e:
|
||
logger.warning(f"[UniLab Registry] 写入设备文件失败: {file}, 错误: {e}")
|
||
|
||
return data, complete_data, True, device_ids
|
||
|
||
def _rebuild_device_runtime_data(self, complete_data: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""从 complete_data(纯字符串)重建运行时数据(type 字段替换为 class 对象)。"""
|
||
data = copy.deepcopy(complete_data)
|
||
for device_id, device_config in data.items():
|
||
if "class" not in device_config:
|
||
continue
|
||
# status_types: str → class
|
||
for st_name, st_type in device_config["class"].get("status_types", {}).items():
|
||
if isinstance(st_type, str):
|
||
device_config["class"]["status_types"][st_name] = self._replace_type_with_class(
|
||
st_type, device_id, f"状态 {st_name}"
|
||
)
|
||
# action type: str → class (non-UniLabJsonCommand only)
|
||
for _act_name, act_cfg in device_config["class"].get("action_value_mappings", {}).items():
|
||
t_ref = act_cfg.get("type", "")
|
||
if isinstance(t_ref, str) and t_ref and not t_ref.startswith("UniLabJsonCommand"):
|
||
resolved = self._replace_type_with_class(t_ref, device_id, f"动作 {_act_name}")
|
||
if resolved:
|
||
act_cfg["type"] = resolved
|
||
self._add_builtin_actions(device_config, device_id)
|
||
return data
|
||
|
||
def load_device_types(self, path: os.PathLike, complete_registry: bool = False):
|
||
import hashlib as _hl
|
||
t0 = time.time()
|
||
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(
|
||
f"[UniLab Registry] devices: {devices_path.exists()}, device_comms: {device_comms_path.exists()}, "
|
||
+ f"total: {len(files)}"
|
||
)
|
||
|
||
if not files:
|
||
return
|
||
|
||
config_cache = self._load_config_cache()
|
||
yaml_dev_cache: dict = config_cache.get("_yaml_devices", {})
|
||
cache_hits = 0
|
||
uncached_files: list[Path] = []
|
||
|
||
if complete_registry:
|
||
uncached_files = files
|
||
else:
|
||
for file in files:
|
||
file_key = str(file.absolute()).replace("\\", "/")
|
||
try:
|
||
yaml_md5 = _hl.md5(file.read_bytes()).hexdigest()
|
||
except OSError:
|
||
uncached_files.append(file)
|
||
continue
|
||
cached = yaml_dev_cache.get(file_key)
|
||
if cached and cached.get("yaml_md5") == yaml_md5 and cached.get("entries"):
|
||
complete_data = cached["entries"]
|
||
# 过滤掉 AST 已有的设备
|
||
complete_data = {
|
||
did: cfg for did, cfg in complete_data.items()
|
||
if not self.device_type_registry.get(did)
|
||
}
|
||
runtime_data = self._rebuild_device_runtime_data(complete_data)
|
||
self.device_type_registry.update(runtime_data)
|
||
cache_hits += 1
|
||
continue
|
||
uncached_files.append(file)
|
||
|
||
executor = self._startup_executor
|
||
future_to_file = {
|
||
executor.submit(
|
||
self._load_single_device_file, file, complete_registry
|
||
): file
|
||
for file in uncached_files
|
||
}
|
||
|
||
for future in as_completed(future_to_file):
|
||
file = future_to_file[future]
|
||
try:
|
||
data, _complete_data, is_valid, device_ids = future.result()
|
||
if is_valid:
|
||
runtime_data = {did: data[did] for did in device_ids if did in data}
|
||
self.device_type_registry.update(runtime_data)
|
||
# 写入缓存
|
||
file_key = str(file.absolute()).replace("\\", "/")
|
||
try:
|
||
yaml_md5 = _hl.md5(file.read_bytes()).hexdigest()
|
||
yaml_dev_cache[file_key] = {
|
||
"yaml_md5": yaml_md5,
|
||
"entries": _complete_data,
|
||
}
|
||
except OSError:
|
||
pass
|
||
except Exception as e:
|
||
logger.warning(f"[UniLab Registry] 加载设备文件失败: {file}, 错误: {e}")
|
||
|
||
if uncached_files and yaml_dev_cache:
|
||
latest_cache = self._load_config_cache()
|
||
latest_cache["_yaml_devices"] = yaml_dev_cache
|
||
self._save_config_cache(latest_cache)
|
||
|
||
total = len(files)
|
||
extra = " (complete_registry 跳过缓存)" if complete_registry else ""
|
||
logger.info(
|
||
f"[UniLab Registry] YAML 设备加载: "
|
||
f"{cache_hits}/{total} 缓存命中, "
|
||
f"{len(uncached_files)} 重新加载 "
|
||
f"(耗时 {time.time() - t0:.2f}s){extra}"
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# 注册表信息输出
|
||
# ------------------------------------------------------------------
|
||
|
||
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():
|
||
type_obj = action_config.get("type")
|
||
if hasattr(type_obj, "__name__"):
|
||
action_config["type"] = type_obj.__name__
|
||
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"
|
||
]
|
||
status_types = device_info_copy["class"].get("status_types", {})
|
||
for status_name, status_type in status_types.items():
|
||
if hasattr(status_type, "__name__"):
|
||
status_types[status_name] = status_type.__name__
|
||
|
||
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
|
||
|
||
def get_yaml_output(self, device_id: str) -> str:
|
||
"""将指定设备的注册表条目导出为 YAML 字符串。"""
|
||
entry = self.device_type_registry.get(device_id)
|
||
if not entry:
|
||
return ""
|
||
|
||
entry = copy.deepcopy(entry)
|
||
|
||
if "class" in entry:
|
||
status_types = entry["class"].get("status_types", {})
|
||
for name, type_obj in status_types.items():
|
||
if hasattr(type_obj, "__name__"):
|
||
status_types[name] = type_obj.__name__
|
||
|
||
for action_name, action_config in entry["class"].get("action_value_mappings", {}).items():
|
||
type_obj = action_config.get("type")
|
||
if hasattr(type_obj, "__name__"):
|
||
action_config["type"] = type_obj.__name__
|
||
|
||
entry.pop("registry_type", None)
|
||
entry.pop("file_path", None)
|
||
|
||
if "class" in entry and "action_value_mappings" in entry["class"]:
|
||
entry["class"]["action_value_mappings"] = {
|
||
k: v
|
||
for k, v in entry["class"]["action_value_mappings"].items()
|
||
if not k.startswith("_execute_driver_command")
|
||
}
|
||
|
||
return yaml.dump(
|
||
{device_id: entry},
|
||
allow_unicode=True,
|
||
default_flow_style=False,
|
||
Dumper=NoAliasDumper,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 全局单例实例 & 构建入口
|
||
# ---------------------------------------------------------------------------
|
||
|
||
lab_registry = Registry()
|
||
|
||
|
||
def build_registry(registry_paths=None, devices_dirs=None, upload_registry=False, check_mode=False, complete_registry=False, external_only=False):
|
||
"""
|
||
构建或获取Registry单例实例
|
||
"""
|
||
logger.info("[UniLab Registry] 构建注册表实例")
|
||
|
||
global lab_registry
|
||
|
||
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(devices_dirs=devices_dirs, upload_registry=upload_registry, complete_registry=complete_registry, external_only=external_only)
|
||
|
||
# 将 AST 扫描的字符串类型替换为实际 ROS2 消息类(仅查找 ROS2 类型,不 import 设备模块)
|
||
lab_registry.resolve_all_types()
|
||
|
||
if check_mode:
|
||
lab_registry.verify_and_resolve_registry()
|
||
|
||
# noinspection PyProtectedMember
|
||
if lab_registry._startup_executor is not None:
|
||
# noinspection PyProtectedMember
|
||
lab_registry._startup_executor.shutdown(wait=False)
|
||
lab_registry._startup_executor = None
|
||
|
||
return lab_registry
|