克隆后还缺仿真安装树和两边的编译目录。把 build、build_sim、install_sim 一并放进仓库;仿真入口不再写死本机 conda,build 里指向源码的链接改成相对路径。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# EASY-INSTALL-ENTRY-SCRIPT: 'arm_interfaces==0.1.0','console_scripts','device_check'
|
||||
import re
|
||||
import sys
|
||||
|
||||
# for compatibility with easy_install; see #2198
|
||||
__requires__ = 'arm_interfaces==0.1.0'
|
||||
|
||||
try:
|
||||
from importlib.metadata import distribution
|
||||
except ImportError:
|
||||
try:
|
||||
from importlib_metadata import distribution
|
||||
except ImportError:
|
||||
from pkg_resources import load_entry_point
|
||||
|
||||
|
||||
def importlib_load_entry_point(spec, group, name):
|
||||
dist_name, _, _ = spec.partition('==')
|
||||
matches = (
|
||||
entry_point
|
||||
for entry_point in distribution(dist_name).entry_points
|
||||
if entry_point.group == group and entry_point.name == name
|
||||
)
|
||||
return next(matches).load()
|
||||
|
||||
|
||||
globals().setdefault('load_entry_point', importlib_load_entry_point)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(load_entry_point('arm_interfaces==0.1.0', 'console_scripts', 'device_check')())
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: arm_interfaces
|
||||
Version: 0.1.0
|
||||
Summary: Device contract layer for the robotic arm stack.
|
||||
Maintainer: arm_dev
|
||||
Maintainer-email: dev@example.com
|
||||
License: Apache-2.0
|
||||
Requires-Dist: setuptools
|
||||
Requires-Dist: PyYAML
|
||||
Dynamic: license
|
||||
Dynamic: maintainer
|
||||
Dynamic: maintainer-email
|
||||
Dynamic: requires-dist
|
||||
Dynamic: summary
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package.xml
|
||||
setup.cfg
|
||||
setup.py
|
||||
../../build_sim/arm_interfaces/arm_interfaces.egg-info/PKG-INFO
|
||||
../../build_sim/arm_interfaces/arm_interfaces.egg-info/SOURCES.txt
|
||||
../../build_sim/arm_interfaces/arm_interfaces.egg-info/dependency_links.txt
|
||||
../../build_sim/arm_interfaces/arm_interfaces.egg-info/entry_points.txt
|
||||
../../build_sim/arm_interfaces/arm_interfaces.egg-info/requires.txt
|
||||
../../build_sim/arm_interfaces/arm_interfaces.egg-info/top_level.txt
|
||||
../../build_sim/arm_interfaces/arm_interfaces.egg-info/zip-safe
|
||||
arm_interfaces/__init__.py
|
||||
arm_interfaces/capabilities.py
|
||||
arm_interfaces/cli.py
|
||||
arm_interfaces/config_guard.py
|
||||
arm_interfaces/errors.py
|
||||
arm_interfaces/loader.py
|
||||
arm_interfaces/manifest.py
|
||||
arm_interfaces/paths.py
|
||||
arm_interfaces/registry.py
|
||||
resource/arm_interfaces
|
||||
test/test_check_arm_prefixes.py
|
||||
test/test_contract.py
|
||||
test/test_real_devices.py
|
||||
test/test_site_env.py
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
[console_scripts]
|
||||
device_check = arm_interfaces.cli:main
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
setuptools
|
||||
PyYAML
|
||||
+1
@@ -0,0 +1 @@
|
||||
arm_interfaces
|
||||
+1
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""机械臂栈的设备契约层。
|
||||
|
||||
公开入口::
|
||||
|
||||
from arm_interfaces import load_device_stack, check_requirement
|
||||
|
||||
stack = load_device_stack() # 失败时抛出带修复提示的 ContractError
|
||||
check_requirement(stack, "handeye") # 能力不满足时抛出 CapabilityError
|
||||
|
||||
所有与设备选型相关的事都汇集到这里,框架其余部分因此永远不必自己读
|
||||
``devices/``,也不必猜测 ``site.yaml`` 的结构。
|
||||
"""
|
||||
|
||||
from .capabilities import (
|
||||
ALL_CAPABILITIES,
|
||||
CAPABILITIES_BY_KIND,
|
||||
REQUIREMENTS,
|
||||
ROS_INTERFACE,
|
||||
capabilities_for_kind,
|
||||
describe,
|
||||
)
|
||||
from .config_guard import check_config, load_site, resolve_path
|
||||
from .errors import (
|
||||
BadConfigValueError,
|
||||
CapabilityError,
|
||||
ContractError,
|
||||
ManifestError,
|
||||
MissingAssetError,
|
||||
MissingConfigError,
|
||||
RegistryError,
|
||||
format_error,
|
||||
)
|
||||
from .loader import (
|
||||
DeviceStack,
|
||||
check_requirement,
|
||||
effective_capabilities,
|
||||
load_device_stack,
|
||||
)
|
||||
from .manifest import Manifest, check_assets, load_manifest, manifest_path
|
||||
from .registry import Registry, list_available_brands, load_registry
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
# 加载
|
||||
"load_device_stack",
|
||||
"load_registry",
|
||||
"load_manifest",
|
||||
"load_site",
|
||||
"check_config",
|
||||
"check_assets",
|
||||
"check_requirement",
|
||||
"resolve_path",
|
||||
"effective_capabilities",
|
||||
"list_available_brands",
|
||||
"manifest_path",
|
||||
# 类型
|
||||
"DeviceStack",
|
||||
"Manifest",
|
||||
"Registry",
|
||||
# 能力
|
||||
"ALL_CAPABILITIES",
|
||||
"CAPABILITIES_BY_KIND",
|
||||
"REQUIREMENTS",
|
||||
"ROS_INTERFACE",
|
||||
"capabilities_for_kind",
|
||||
"describe",
|
||||
# 错误
|
||||
"ContractError",
|
||||
"RegistryError",
|
||||
"ManifestError",
|
||||
"MissingAssetError",
|
||||
"MissingConfigError",
|
||||
"CapabilityError",
|
||||
"BadConfigValueError",
|
||||
"format_error",
|
||||
]
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
"""能力词表,以及上层依赖的能力需求集。
|
||||
|
||||
这里有彼此不同的两个概念,把它们分开很重要:
|
||||
|
||||
``Capability``
|
||||
*驱动*能做什么。由每个品牌的 ``manifest.yaml`` 声明。新增品牌从不改动
|
||||
本文件——只有当确实要表达一种新的运动或感知方式时才编辑它。
|
||||
|
||||
``Requirement``
|
||||
*上层*需要什么。一个流程(视觉跟踪、手眼标定、通止规装配状态机)声明
|
||||
它将要调用的能力集合。契约层在启动时检查所选驱动是否满足它,因此缺失
|
||||
能力会立刻报出清晰的消息,而不是在运动中途才失败。
|
||||
|
||||
``ROS_INTERFACE`` 表把每项能力映射到实现它的具体 ROS 2 端点。它的存在,
|
||||
一是让错误消息能点名操作员该检查哪个接口,二是让未来的驱动清楚自己该
|
||||
发布或提供什么。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, FrozenSet, NamedTuple, Tuple
|
||||
|
||||
|
||||
class CapabilityInfo(NamedTuple):
|
||||
"""对某一项能力的说明。"""
|
||||
|
||||
owner: str
|
||||
"""由哪类设备提供:robot | gripper | sensor | camera。"""
|
||||
|
||||
summary: str
|
||||
"""一句话说明该能力做什么。"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 机器人能力
|
||||
# ---------------------------------------------------------------------------
|
||||
ROBOT_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"get_arm_state": CapabilityInfo(
|
||||
"robot", "读关节角与末端位姿。手眼标定与状态记录依赖它。"
|
||||
),
|
||||
"follow_joint_trajectory": CapabilityInfo(
|
||||
"robot", "关节空间轨迹(点到点与多路点)。手眼走点、装配移动依赖它。"
|
||||
),
|
||||
"set_named_pose": CapabilityInfo(
|
||||
"robot", "移动到命名位姿(如 home)。启动归位与异常复位依赖它。"
|
||||
),
|
||||
"servo_joint": CapabilityInfo(
|
||||
"robot", "高频关节伺服(~100Hz)。视觉伺服 servo_node 依赖它。"
|
||||
),
|
||||
"servo_cartesian": CapabilityInfo(
|
||||
"robot", "笛卡尔位姿伺服。视觉跟踪与悬停依赖它。"
|
||||
),
|
||||
"compliant_descent": CapabilityInfo(
|
||||
"robot", "柔顺下臂 —— 带接触力约束的下压。装配接触阶段(S07)依赖它。"
|
||||
),
|
||||
"switch_tool": CapabilityInfo(
|
||||
"robot",
|
||||
"切换到已保存的工具坐标系(如切到 tool1)。"
|
||||
"RDK 只提供按名字切换(Tool.Switch),工具的参数(质量/质心/TCP)在 Studio 里预配好。"
|
||||
"全局工具系锁定依赖它。",
|
||||
),
|
||||
"home": CapabilityInfo("robot", "回到初始位姿。每个装配循环开始与异常复位依赖它。"),
|
||||
"enable": CapabilityInfo("robot", "伺服使能。启动自检依赖它。"),
|
||||
"clear_fault": CapabilityInfo("robot", "清除故障。异常恢复依赖它。"),
|
||||
"soft_drag": CapabilityInfo(
|
||||
"robot", "软拖拽示教。手眼标定走点依赖它。"
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 夹爪能力
|
||||
# ---------------------------------------------------------------------------
|
||||
GRIPPER_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"open": CapabilityInfo("gripper", "张开。"),
|
||||
"close": CapabilityInfo("gripper", "夹紧(可带力与速度参数)。"),
|
||||
"move": CapabilityInfo("gripper", "移动到指定开度。"),
|
||||
"rotate": CapabilityInfo(
|
||||
"gripper", "旋转到指定角度。通止规装配 S08 循环的核心原语。"
|
||||
),
|
||||
"get_status": CapabilityInfo("gripper", "读开度、力、是否夹持到位。"),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 传感器能力
|
||||
# ---------------------------------------------------------------------------
|
||||
SENSOR_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"read_tangential": CapabilityInfo(
|
||||
"sensor", "读切向力(两条正交通道)。接触判定与拧紧判定依赖它。"
|
||||
),
|
||||
"read_normal": CapabilityInfo("sensor", "读法向力。拧紧判定与安全上限依赖它。"),
|
||||
"read_depth": CapabilityInfo("sensor", "读深度图。"),
|
||||
"read_force_vector": CapabilityInfo("sensor", "读力矢量图。"),
|
||||
"measure_baseline": CapabilityInfo(
|
||||
"sensor", "测静止基线。所有力阈值都是相对基线的增量,因此这是必需能力。"
|
||||
),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 相机能力
|
||||
# ---------------------------------------------------------------------------
|
||||
CAMERA_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
"color": CapabilityInfo("camera", "彩色图。推理与跟踪依赖它。"),
|
||||
"depth": CapabilityInfo("camera", "深度图。"),
|
||||
"aligned_depth_to_color": CapabilityInfo(
|
||||
"camera", "对齐到彩图的深度。把像素换算成三维坐标依赖它。"
|
||||
),
|
||||
"infrared_left": CapabilityInfo("camera", "左红外。"),
|
||||
"infrared_right": CapabilityInfo("camera", "右红外。"),
|
||||
"query_intrinsics": CapabilityInfo(
|
||||
"camera", "查询内参。手眼标定必需 —— 没有内参无法把像素反投成射线。"
|
||||
),
|
||||
"set_exposure": CapabilityInfo("camera", "设置曝光。跟踪需要限制曝光避免运动拖影。"),
|
||||
}
|
||||
|
||||
#: 框架已知的全部能力名,并标注其设备类别。
|
||||
ALL_CAPABILITIES: Dict[str, CapabilityInfo] = {
|
||||
**ROBOT_CAPABILITIES,
|
||||
**GRIPPER_CAPABILITIES,
|
||||
**SENSOR_CAPABILITIES,
|
||||
**CAMERA_CAPABILITIES,
|
||||
}
|
||||
|
||||
#: 设备类别 -> 该类设备允许声明的能力名。
|
||||
CAPABILITIES_BY_KIND: Dict[str, FrozenSet[str]] = {
|
||||
"robot": frozenset(ROBOT_CAPABILITIES),
|
||||
"gripper": frozenset(GRIPPER_CAPABILITIES),
|
||||
"sensor": frozenset(SENSOR_CAPABILITIES),
|
||||
"camera": frozenset(CAMERA_CAPABILITIES),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ROS 2 接口映射
|
||||
# ---------------------------------------------------------------------------
|
||||
class RosInterface(NamedTuple):
|
||||
"""实现某项能力的具体 ROS 2 端点。"""
|
||||
|
||||
namespace: str
|
||||
name: str
|
||||
kind: str # topic | service | action
|
||||
type: str
|
||||
|
||||
|
||||
#: capability -> ROS 2 端点。名称相对于设备命名空间,且按实例解析
|
||||
#:(两条机械臂绝不共用命名空间)。
|
||||
ROS_INTERFACE: Dict[str, RosInterface] = {
|
||||
# --- 机器人 ---
|
||||
"get_arm_state": RosInterface("", "get_arm_state", "service", "arm_msgs/srv/GetArmState"),
|
||||
"follow_joint_trajectory": RosInterface(
|
||||
"", "follow_joint_trajectory", "action", "arm_msgs/action/FollowJointTrajectory"
|
||||
),
|
||||
"set_named_pose": RosInterface("", "set_named_pose", "service", "arm_msgs/srv/SetNamedPose"),
|
||||
"servo_joint": RosInterface("", "joint_command", "topic", "sensor_msgs/msg/JointState"),
|
||||
"servo_cartesian": RosInterface("servo", "target_pose", "topic", "geometry_msgs/msg/PoseStamped"),
|
||||
"compliant_descent": RosInterface(
|
||||
"", "compliant_descent", "action", "arm_msgs/action/CompliantDescent"
|
||||
),
|
||||
"switch_tool": RosInterface("", "switch_tool", "service", "arm_msgs/srv/SwitchTool"),
|
||||
"home": RosInterface("", "home", "service", "std_srvs/srv/Trigger"),
|
||||
"enable": RosInterface("", "enable", "service", "std_srvs/srv/SetBool"),
|
||||
"clear_fault": RosInterface("", "clear_fault", "service", "std_srvs/srv/Trigger"),
|
||||
"soft_drag": RosInterface("", "soft_drag", "service", "std_srvs/srv/SetBool"),
|
||||
# --- 夹爪 ---
|
||||
"open": RosInterface("", "open", "service", "arm_msgs/srv/GripperExecute"),
|
||||
"close": RosInterface("", "close", "service", "arm_msgs/srv/GripperExecute"),
|
||||
"move": RosInterface("", "move", "service", "arm_msgs/srv/GripperExecute"),
|
||||
"rotate": RosInterface("", "rotate", "service", "arm_msgs/srv/GripperExecute"),
|
||||
"get_status": RosInterface("", "status", "topic", "arm_msgs/msg/GripperStatus"),
|
||||
# --- 传感器 ---
|
||||
"read_tangential": RosInterface("", "tangential", "topic", "sensor_msgs/msg/Float32MultiArray"),
|
||||
"read_normal": RosInterface("", "normal", "topic", "sensor_msgs/msg/Float32"),
|
||||
"read_depth": RosInterface("", "depth", "topic", "sensor_msgs/msg/Image"),
|
||||
"read_force_vector": RosInterface("", "force_vector", "topic", "sensor_msgs/msg/Image"),
|
||||
"measure_baseline": RosInterface("", "measure_baseline", "service", "std_srvs/srv/Trigger"),
|
||||
# --- 相机 ---
|
||||
"color": RosInterface("", "color/image_raw", "topic", "sensor_msgs/msg/Image"),
|
||||
"depth": RosInterface("", "depth/image_raw", "topic", "sensor_msgs/msg/Image"),
|
||||
"aligned_depth_to_color": RosInterface(
|
||||
"", "aligned_depth_to_color/image_raw", "topic", "sensor_msgs/msg/Image"
|
||||
),
|
||||
"infrared_left": RosInterface("", "infra1/image_raw", "topic", "sensor_msgs/msg/Image"),
|
||||
"infrared_right": RosInterface("", "infra2/image_raw", "topic", "sensor_msgs/msg/Image"),
|
||||
"query_intrinsics": RosInterface(
|
||||
"", "color/camera_info", "topic", "sensor_msgs/msg/CameraInfo"
|
||||
),
|
||||
"set_exposure": RosInterface("", "set_exposure", "service", "std_srvs/srv/SetBool"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 需求集
|
||||
# ---------------------------------------------------------------------------
|
||||
class Requirement(NamedTuple):
|
||||
"""一个具名流程及其所需能力,按设备类别分组。"""
|
||||
|
||||
summary: str
|
||||
robot: Tuple[str, ...]
|
||||
gripper: Tuple[str, ...]
|
||||
sensor: Tuple[str, ...]
|
||||
camera: Tuple[str, ...]
|
||||
|
||||
|
||||
REQUIREMENTS: Dict[str, Requirement] = {
|
||||
"tracking": Requirement(
|
||||
summary="视觉跟踪 k0(腕部相机实时推理 + 锁高悬停)",
|
||||
robot=("get_arm_state", "servo_joint", "servo_cartesian"),
|
||||
gripper=(),
|
||||
sensor=(),
|
||||
camera=("color", "aligned_depth_to_color", "query_intrinsics"),
|
||||
),
|
||||
"handeye": Requirement(
|
||||
summary="手眼标定(走点 + 拍照 + 解 AX=XB)",
|
||||
robot=("get_arm_state", "follow_joint_trajectory", "soft_drag"),
|
||||
gripper=(),
|
||||
sensor=(),
|
||||
camera=("color", "query_intrinsics"),
|
||||
),
|
||||
"coarse_locate": Requirement(
|
||||
summary="手外粗定位(D435 拍照推理出工件坐标)",
|
||||
robot=("follow_joint_trajectory",),
|
||||
gripper=(),
|
||||
sensor=(),
|
||||
camera=("color", "depth", "query_intrinsics"),
|
||||
),
|
||||
"gauge_assembly": Requirement(
|
||||
summary="通止规装配(接触 + 旋转拧入 + 力判据停止 + 拧出)",
|
||||
robot=(
|
||||
"get_arm_state",
|
||||
"follow_joint_trajectory",
|
||||
"set_named_pose",
|
||||
"servo_cartesian",
|
||||
"compliant_descent",
|
||||
"switch_tool",
|
||||
"home",
|
||||
"enable",
|
||||
"clear_fault",
|
||||
),
|
||||
gripper=("open", "close", "rotate", "get_status"),
|
||||
sensor=("read_tangential", "read_normal", "measure_baseline"),
|
||||
camera=("color", "query_intrinsics"),
|
||||
),
|
||||
"gripper_only": Requirement(
|
||||
summary="仅夹爪控制",
|
||||
robot=(),
|
||||
gripper=("open", "close", "get_status"),
|
||||
sensor=(),
|
||||
camera=(),
|
||||
),
|
||||
"tactile_only": Requirement(
|
||||
summary="仅触觉采集与图传(调阈值用)",
|
||||
robot=(),
|
||||
gripper=(),
|
||||
sensor=("read_tangential", "read_normal", "read_depth", "read_force_vector"),
|
||||
camera=(),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def capabilities_for_kind(kind: str) -> FrozenSet[str]:
|
||||
"""返回某类设备合法的能力名集合。"""
|
||||
return CAPABILITIES_BY_KIND.get(kind, frozenset())
|
||||
|
||||
|
||||
def describe(capability: str) -> str:
|
||||
"""用于错误消息的、给人看的说明。"""
|
||||
info = ALL_CAPABILITIES.get(capability)
|
||||
if info is None:
|
||||
return f"{capability}(框架未登记的能力名)"
|
||||
return f"{capability} —— {info.summary}"
|
||||
@@ -0,0 +1,176 @@
|
||||
"""``device_check`` —— 从命令行校验设备栈。
|
||||
|
||||
用法::
|
||||
|
||||
ros2 run arm_interfaces device_check # 全量加载并打印摘要
|
||||
ros2 run arm_interfaces device_check --requirement handeye
|
||||
ros2 run arm_interfaces device_check --json
|
||||
ros2 run arm_interfaces device_check --list-brands
|
||||
ros2 run arm_interfaces device_check --no-assets # 跳过厂商物料检查
|
||||
|
||||
退出码有明确含义,脚本可据此分支:
|
||||
|
||||
=== ==========================================================
|
||||
0 契约满足
|
||||
2 契约违规(缺少品牌、配置、物料或能力)
|
||||
3 契约层自身出错(意料之外的异常)
|
||||
=== ==========================================================
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import paths
|
||||
from .capabilities import REQUIREMENTS
|
||||
from .errors import ContractError, format_error
|
||||
from .loader import check_requirement, load_device_stack
|
||||
from .registry import REQUIRED_KINDS, list_available_brands
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_CONTRACT = 2
|
||||
EXIT_INTERNAL = 3
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="device_check",
|
||||
description="校验设备契约:devices/registry.yaml -> manifest.yaml -> config/site.yaml",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" device_check # 全量校验并打印摘要\n"
|
||||
" device_check --requirement handeye # 校验手眼标定所需能力\n"
|
||||
" device_check --list-brands # 列出每类设备当前可选的品牌\n"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--requirement",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME",
|
||||
help="额外校验某个流程的能力需求,可重复。可用: " + ", ".join(sorted(REQUIREMENTS)),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-brands", action="store_true", help="列出每类设备可选的品牌后退出"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-assets",
|
||||
action="store_true",
|
||||
help="跳过厂商物料存在性检查(物料还没放好时可用,方便先校验配置)",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="以 JSON 输出结果")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
args = _build_parser().parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.list_brands:
|
||||
return _print_brands(as_json=args.json)
|
||||
|
||||
stack = load_device_stack(verify_assets=not args.no_assets)
|
||||
|
||||
report: Dict[str, Any] = {"ok": True, "devices": {}, "requirements": {}}
|
||||
for kind in (*REQUIRED_KINDS, "sim"):
|
||||
manifest = stack.manifests.get(kind)
|
||||
if manifest is None:
|
||||
continue
|
||||
report["devices"][kind] = {
|
||||
"brand": manifest.id,
|
||||
"label": manifest.label,
|
||||
"version": manifest.version,
|
||||
"capabilities": sorted(stack.capabilities(kind)),
|
||||
"manifest": str(manifest.path),
|
||||
}
|
||||
|
||||
for name in args.requirement:
|
||||
satisfied = check_requirement(stack, name)
|
||||
report["requirements"][name] = {
|
||||
detail: sorted(caps) for detail, caps in satisfied.items()
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
_print_report(stack, report)
|
||||
|
||||
return EXIT_OK
|
||||
|
||||
except ContractError as err:
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": False,
|
||||
"what": err.what,
|
||||
"fix_hint": err.fix_hint,
|
||||
"detail": err.detail,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(format_error(err), file=sys.stderr)
|
||||
return EXIT_CONTRACT
|
||||
|
||||
except Exception as exc: # noqa: BLE001 - CLI 是最后一道防线
|
||||
print(
|
||||
f"契约层内部错误(这本身是框架 bug,请上报): {type(exc).__name__}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_INTERNAL
|
||||
|
||||
|
||||
def _print_brands(as_json: bool = False) -> int:
|
||||
table: Dict[str, List[str]] = {
|
||||
kind: list_available_brands(kind) for kind in (*REQUIRED_KINDS, "sim")
|
||||
}
|
||||
if as_json:
|
||||
print(json.dumps(table, ensure_ascii=False, indent=2))
|
||||
return EXIT_OK
|
||||
|
||||
print(f"设备目录: {paths.devices_root()}")
|
||||
print(f"选型文件: {paths.registry_path()}")
|
||||
print()
|
||||
for kind, brands in table.items():
|
||||
shown = ", ".join(brands) if brands else "(无可用品牌)"
|
||||
print(f" {kind:10s} -> {shown}")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def _print_report(stack, report: Dict[str, Any]) -> None:
|
||||
print("=" * 68)
|
||||
print("设备契约校验通过")
|
||||
print("=" * 68)
|
||||
print(f"选型文件: {stack.registry.path}")
|
||||
print(f"现场配置: {stack.site_path}")
|
||||
print()
|
||||
|
||||
for kind, info in report["devices"].items():
|
||||
print(f"[{kind}] {info['brand']} — {info['label']}")
|
||||
if info["version"]:
|
||||
print(f" 版本: {info['version']}")
|
||||
caps = info["capabilities"]
|
||||
print(f" 能力({len(caps)}): {', '.join(caps) if caps else '(未声明)'}")
|
||||
print()
|
||||
|
||||
if report["requirements"]:
|
||||
print("流程能力校验:")
|
||||
for name, detail in report["requirements"].items():
|
||||
summary = REQUIREMENTS[name].summary
|
||||
print(f" [OK] {name} — {summary}")
|
||||
for kind, caps in detail.items():
|
||||
print(f" {kind}: {', '.join(caps)}")
|
||||
print()
|
||||
|
||||
print("提示: 加 --requirement <流程名> 可额外校验具体流程,例如 --requirement gauge_assembly")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
"""按清单声明的 ``config_schema`` 校验 ``config/site.yaml``。
|
||||
|
||||
有两类 bug 促成了本模块:
|
||||
|
||||
1. **静默失效。** ``run_handeye.sh`` 把五个参数
|
||||
(``settle_qd_max``、``pin_holds``……)传给了一个从未声明它们的节点。
|
||||
YAML 接受了这些值,却没人读它们,调参毫无效果。
|
||||
2. **单位混淆。** 按毫米输入却被当成米解释的值,会产生看似合理但错误的
|
||||
运动。
|
||||
|
||||
配置守卫同时解决这两点:每个已声明的字段都会检查是否存在、类型是否正确,
|
||||
并且 ``unit`` 会回显在错误消息里,让操作员在写下的值旁边就能看到期望的单位。
|
||||
|
||||
``site_path`` 支持列表展开记号,使按实例的配置只需声明一次:
|
||||
``robot.robots[].ip`` 会解析为 ``robot.robots[0].ip`` 和
|
||||
``robot.robots[1].ip``,并要求*每一个*实例都提供该值。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, NamedTuple, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from .errors import BadConfigValueError, MissingConfigError
|
||||
from .manifest import ConfigField, Manifest
|
||||
|
||||
#: 每个已声明类型名所接受的 Python 类型。``bool`` 被刻意排除在数值类型之外:
|
||||
#: 在 YAML 里 ``true`` 是布尔值,把它悄悄当作 1.0 用曾引发过真实 bug。
|
||||
_TYPE_CHECKS = {
|
||||
"str": (str,),
|
||||
"int": (int,),
|
||||
"float": (int, float),
|
||||
"bool": (bool,),
|
||||
"list": (list,),
|
||||
"dict": (dict,),
|
||||
}
|
||||
|
||||
|
||||
class ResolvedValue(NamedTuple):
|
||||
"""在 ``site.yaml`` 中为某个已声明字段找到的一个具体值。"""
|
||||
|
||||
path: str
|
||||
"""具体路径,例如 ``robot.robots[1].ip``。"""
|
||||
|
||||
value: Any
|
||||
|
||||
|
||||
def load_site(path: Path | None = None) -> Dict[str, Any]:
|
||||
"""读取 ``config/site.yaml``。
|
||||
|
||||
文件不存在时返回空字典,与旧 ``site_io.load_site_dict`` 的宽松行为保持
|
||||
一致——缺失会由配置守卫报告为“缺少必需配置”,而不是解析崩溃。
|
||||
"""
|
||||
from . import paths
|
||||
|
||||
target = path or paths.site_path()
|
||||
if not target.is_file():
|
||||
return {}
|
||||
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def resolve_path(site: Dict[str, Any], site_path: str) -> List[ResolvedValue]:
|
||||
"""在解析后的 YAML 上解析一个(可能带列表展开的)点分路径。
|
||||
|
||||
``[]`` 记号会把一个集合展开成每个成员一条记录,因此按实例的配置只需
|
||||
声明一次,却仍要求*每一个*实例都提供它:
|
||||
|
||||
* 序列 — ``robot.robots[].sn`` → ``robot.robots[0].sn``、``...[1].sn``
|
||||
* 映射 — ``cameras[].serial`` → ``cameras.d405_left.serial``……
|
||||
|
||||
之所以支持映射,是因为 ``site.yaml`` 把相机(以及其他具名设备)登记为
|
||||
以设备名为键的映射,而不是序列。
|
||||
|
||||
缺失的分支不产生结果;是否算错误由调用方决定。在本该是集合的位置出现
|
||||
*标量*(``cameras: 5``)时,会作为在集合层级解析出的单个值上报,让类型
|
||||
检查能指出问题,而不是误导性地告诉操作员“该值不存在”。
|
||||
"""
|
||||
tokens = [t.strip() for t in site_path.split(".") if t.strip()]
|
||||
return _resolve(site, tokens, "")
|
||||
|
||||
|
||||
def _resolve(node: Any, tokens: List[str], prefix: str) -> List[ResolvedValue]:
|
||||
""":func:`resolve_path` 的递归工作函数。"""
|
||||
if not tokens:
|
||||
return [ResolvedValue(path=prefix, value=node)]
|
||||
|
||||
token = tokens[0]
|
||||
rest = tokens[1:]
|
||||
expand = token.endswith("[]")
|
||||
key = token[:-2] if expand else token
|
||||
|
||||
if not isinstance(node, dict) or key not in node:
|
||||
return []
|
||||
|
||||
child = node[key]
|
||||
child_path = f"{prefix}.{key}" if prefix else key
|
||||
|
||||
if not expand:
|
||||
return _resolve(child, rest, child_path)
|
||||
|
||||
if isinstance(child, list):
|
||||
out: List[ResolvedValue] = []
|
||||
for index, item in enumerate(child):
|
||||
out.extend(_resolve(item, rest, f"{child_path}[{index}]"))
|
||||
return out
|
||||
|
||||
if isinstance(child, dict):
|
||||
out = []
|
||||
for name, item in child.items():
|
||||
out.extend(_resolve(item, rest, f"{child_path}.{name}"))
|
||||
return out
|
||||
|
||||
if child is None:
|
||||
return []
|
||||
|
||||
# 声明为集合的位置却是标量。在这里停下,让类型检查给出精确的报错,
|
||||
# 而不是误导性的“缺失”。
|
||||
return [ResolvedValue(path=child_path, value=child)]
|
||||
|
||||
|
||||
def check_config(
|
||||
manifest: Manifest,
|
||||
site: Dict[str, Any],
|
||||
site_file: Optional[Path] = None,
|
||||
) -> Dict[str, List[ResolvedValue]]:
|
||||
"""按解析后的现场配置校验清单中所有已声明的字段。
|
||||
|
||||
返回 ``field.key`` 到已解析值的映射,这样本来就需要这些值的调用方不必
|
||||
再解析第二遍。
|
||||
|
||||
Raises
|
||||
------
|
||||
MissingConfigError
|
||||
某个必需字段在 ``site.yaml`` 中没有值。
|
||||
BadConfigValueError
|
||||
某个字段存在,但字段值的 YAML 类型不对。
|
||||
"""
|
||||
resolved: Dict[str, List[ResolvedValue]] = {}
|
||||
problems: List[str] = []
|
||||
|
||||
for field in manifest.config_schema:
|
||||
values = resolve_path(site, field.site_path)
|
||||
resolved[field.key] = values
|
||||
|
||||
if not values:
|
||||
if field.required:
|
||||
problems.append(_missing_message(field))
|
||||
continue
|
||||
|
||||
# 带按实例展开的必需字段需要*每一个*实例都填好。若只在全部为空时
|
||||
# 才报告,就会放过某一台未配置的机械臂。
|
||||
if field.required:
|
||||
blank_paths = [v.path for v in values if _is_blank(v.value)]
|
||||
if blank_paths:
|
||||
problems.append(_missing_message(field, blank_paths))
|
||||
continue
|
||||
|
||||
for item in values:
|
||||
if _is_blank(item.value):
|
||||
continue
|
||||
if not _matches_type(item.value, field.type):
|
||||
raise BadConfigValueError(
|
||||
f"config/site.yaml 的 '{item.path}' 类型不对"
|
||||
f"(清单 '{manifest.id}' 的 '{field.key}' 要求 {field.type})",
|
||||
fix_hint=(
|
||||
f"把 {item.path} 改成 {field.type} 类型的值"
|
||||
+ (f",单位是 {field.unit}" if field.unit else "")
|
||||
+ (f"\n说明: {field.doc}" if field.doc else "")
|
||||
),
|
||||
detail=(
|
||||
f"实际值: {item.value!r}({type(item.value).__name__})"
|
||||
),
|
||||
)
|
||||
|
||||
if problems:
|
||||
target = site_file or _default_site_file()
|
||||
raise MissingConfigError(
|
||||
f"config/site.yaml 缺少 {len(problems)} 项必需配置"
|
||||
f"(品牌 '{manifest.id}')",
|
||||
fix_hint=(
|
||||
f"编辑 {target},按下面每一项给出的 site_path 补上对应的键。"
|
||||
"若整段缺失,可直接照抄 config/site.example.yaml 里的同名段"
|
||||
),
|
||||
detail="\n".join(problems),
|
||||
)
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def _default_site_file() -> Path:
|
||||
from . import paths
|
||||
|
||||
return paths.site_path()
|
||||
|
||||
|
||||
def _is_blank(value: Any) -> bool:
|
||||
"""判断某个值是否应被视为“未提供”。"""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return True
|
||||
if isinstance(value, (list, dict)) and len(value) == 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _matches_type(value: Any, declared: str) -> bool:
|
||||
expected = _TYPE_CHECKS.get(declared)
|
||||
if expected is None:
|
||||
# 未知的已声明类型会在清单加载阶段被拒。
|
||||
return True
|
||||
if declared in ("int", "float") and isinstance(value, bool):
|
||||
return False
|
||||
return isinstance(value, expected)
|
||||
|
||||
|
||||
def _missing_message(field: ConfigField, blank_paths: Optional[List[str]] = None) -> str:
|
||||
"""渲染一条缺失配置问题。
|
||||
|
||||
``blank_paths`` 列出存在但为空的具体按实例路径(例如
|
||||
``robot.robots[1].sn``),这远比重复抽象的声明路径更具可操作性。
|
||||
"""
|
||||
lines = [f" 缺: {field.key}"]
|
||||
if blank_paths:
|
||||
lines.append(" 已有该键但值为空,需要补值的实例:")
|
||||
for path in blank_paths:
|
||||
lines.append(f" {path}")
|
||||
lines.append(f" 去这里补: config/site.yaml -> {field.site_path}")
|
||||
if field.unit:
|
||||
lines.append(f" 单位: {field.unit}")
|
||||
if field.default is not None:
|
||||
lines.append(f" 建议默认值: {field.default}")
|
||||
if field.doc:
|
||||
lines.append(f" 说明: {field.doc}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""结构化的契约错误。
|
||||
|
||||
每个错误都带有 ``fix_hint``,明确指出人应该去改哪个文件、哪个键。这是刻意
|
||||
设计的:契约层的全部意义,就是把“某个东西静默地什么都没做”(旧行为,例如
|
||||
``run_handeye.sh`` 传给节点的五个参数节点根本不曾声明)替换成一条可执行的
|
||||
提示。
|
||||
|
||||
调用方应当用 :func:`format_error` 格式化错误,它会把修复提示单独渲染成
|
||||
一行缩进文本,在终端里绝不会被漏看。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ContractError(Exception):
|
||||
"""所有设备契约失败的基类。
|
||||
|
||||
Attributes
|
||||
----------
|
||||
what:
|
||||
一句话说明哪里不对。
|
||||
fix_hint:
|
||||
指明要改哪个文件、哪个键的具体指示。``None`` 表示调用方无法确定
|
||||
补救办法,此时调用方应把它当作契约层的 bug,而不是用户错误。
|
||||
detail:
|
||||
可选的补充上下文(尝试过的路径、读到的值……)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
what: str,
|
||||
fix_hint: Optional[str] = None,
|
||||
detail: Optional[str] = None,
|
||||
) -> None:
|
||||
super().__init__(what)
|
||||
self.what = what
|
||||
self.fix_hint = fix_hint
|
||||
self.detail = detail
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - 通过 format_error 覆盖
|
||||
return format_error(self)
|
||||
|
||||
|
||||
class RegistryError(ContractError):
|
||||
"""``devices/registry.yaml`` 缺失、不可读或不完整。"""
|
||||
|
||||
|
||||
class ManifestError(ContractError):
|
||||
"""某个品牌的 ``manifest.yaml`` 缺失、不可读或格式错误。"""
|
||||
|
||||
|
||||
class MissingAssetError(ContractError):
|
||||
"""清单中声明为必需的厂商物料不在磁盘上。"""
|
||||
|
||||
|
||||
class MissingConfigError(ContractError):
|
||||
"""``config/site.yaml`` 未提供某个必需的已声明配置键。"""
|
||||
|
||||
|
||||
class CapabilityError(ContractError):
|
||||
"""上层请求了所选驱动未声明的能力。"""
|
||||
|
||||
|
||||
class BadConfigValueError(ContractError):
|
||||
"""某个配置键存在,但值的类型或结构不对。"""
|
||||
|
||||
|
||||
def format_error(err: ContractError) -> str:
|
||||
"""把契约错误渲染成多行、可执行的中文提示。"""
|
||||
lines = [f"设备契约校验失败: {err.what}"]
|
||||
if err.detail:
|
||||
for line in str(err.detail).splitlines():
|
||||
lines.append(f" {line}")
|
||||
if err.fix_hint:
|
||||
lines.append(f" 怎么修: {err.fix_hint}")
|
||||
else:
|
||||
lines.append(" 怎么修: (契约层未给出修复建议,这本身是框架 bug,请上报)")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""加载整个设备栈,并据此校验各流程。
|
||||
|
||||
这是框架其余部分使用的唯一入口。它依次串联:
|
||||
|
||||
1. :mod:`arm_interfaces.registry` —— 每一类设备由哪个品牌实现
|
||||
2. :mod:`arm_interfaces.manifest` —— 该品牌的能力、物料、配置
|
||||
3. :mod:`arm_interfaces.config_guard` —— ``site.yaml`` 是否提供了那份配置
|
||||
|
||||
然后暴露 :func:`check_requirement`,用来验证某个具名流程
|
||||
(跟踪、手眼、通止规装配……)只索要所选驱动确实声明了的能力。
|
||||
|
||||
收益体现在启动时:不会等到第一次运动才发现所选夹爪不能旋转,而是直接
|
||||
拒绝启动,并点出缺失的能力、它对应的接口,以及该改哪个清单。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, NamedTuple, Optional, Sequence, Set
|
||||
|
||||
from . import paths
|
||||
from .capabilities import REQUIREMENTS, ROS_INTERFACE, describe
|
||||
from .config_guard import ResolvedValue, check_config, load_site
|
||||
from .errors import CapabilityError, ContractError
|
||||
from .manifest import KIND_SINGULAR, Manifest, check_assets, load_manifest
|
||||
from .registry import REQUIRED_KINDS, Registry, load_registry
|
||||
|
||||
|
||||
class DeviceStack(NamedTuple):
|
||||
"""当前所选硬件的全部信息。"""
|
||||
|
||||
registry: Registry
|
||||
manifests: Dict[str, Manifest]
|
||||
"""选型类别 -> 已加载的清单,例如 ``{"robots": <Manifest>}``。"""
|
||||
|
||||
config: Dict[str, List[ResolvedValue]]
|
||||
"""``f"{kind}.{field.key}"`` -> 解析出的 site.yaml 值。"""
|
||||
|
||||
site_path: Path
|
||||
"""被校验过的 ``config/site.yaml``。"""
|
||||
|
||||
def manifest(self, kind: str) -> Manifest:
|
||||
"""返回某个选型类别的清单。"""
|
||||
return self.manifests[kind]
|
||||
|
||||
def capabilities(self, kind: str) -> Set[str]:
|
||||
"""某类设备所选驱动的有效能力。"""
|
||||
manifest = self.manifests.get(kind)
|
||||
if manifest is None:
|
||||
return set()
|
||||
return set(effective_capabilities(manifest, self._selected_models(kind)))
|
||||
|
||||
def _selected_models(self, kind: str) -> List[str]:
|
||||
"""某类设备在 site.yaml 中选中的型号 id(可能为空)。"""
|
||||
manifest = self.manifests.get(kind)
|
||||
if manifest is None:
|
||||
return []
|
||||
selector = manifest.raw.get("model_selector")
|
||||
if not selector:
|
||||
return []
|
||||
found: List[str] = []
|
||||
for entry in self.config.get(f"{kind}.{selector}", []):
|
||||
if isinstance(entry.value, str) and entry.value.strip():
|
||||
found.append(entry.value.strip())
|
||||
return found
|
||||
|
||||
|
||||
def effective_capabilities(
|
||||
manifest: Manifest, selected_models: Sequence[str] = ()
|
||||
) -> Set[str]:
|
||||
"""在给定选中哪些型号的前提下,驱动提供的能力。
|
||||
|
||||
顶层 ``capabilities`` 是该品牌*每一个*型号都支持的能力。某个型号可以
|
||||
追加更多(RGI 在共有的 open/close 之上增加了 ``rotate``)。已声明的
|
||||
能力绝不会被移除,因为悄悄收窄会把清晰的启动报错变成令人困惑的运行时
|
||||
失败。
|
||||
"""
|
||||
caps: Set[str] = set(manifest.capabilities)
|
||||
for model_id in selected_models:
|
||||
model_caps = manifest.model_capabilities(model_id)
|
||||
if model_caps:
|
||||
caps |= set(model_caps)
|
||||
return caps
|
||||
|
||||
|
||||
def load_device_stack(
|
||||
registry_path: Optional[Path] = None,
|
||||
site_file: Optional[Path] = None,
|
||||
*,
|
||||
verify_assets: bool = True,
|
||||
) -> DeviceStack:
|
||||
"""加载并校验整个设备栈。
|
||||
|
||||
*所有*设备类别的校验失败都会被收集起来一并报告。只报告第一个,会迫使
|
||||
操作员每修一个缺失的键就要重跑一次检查,而这种来回折腾正是本层要消除的。
|
||||
|
||||
Raises
|
||||
------
|
||||
ContractError 子类
|
||||
出现契约违规时抛出,并附带可执行的修复提示。当不止一类设备失败时,
|
||||
会抛出单个聚合的 :class:`ContractError`,其 detail 列出全部问题。
|
||||
"""
|
||||
registry = load_registry(registry_path)
|
||||
site_file = site_file or paths.site_path()
|
||||
site = load_site(site_file)
|
||||
|
||||
manifests: Dict[str, Manifest] = {}
|
||||
config: Dict[str, List[ResolvedValue]] = {}
|
||||
problems: List[ContractError] = []
|
||||
|
||||
for kind in (*REQUIRED_KINDS, "sim"):
|
||||
# 完全没有选型的类别,除非是必需类别,否则可选。
|
||||
if kind not in registry.selections:
|
||||
continue
|
||||
|
||||
brand = registry.brand(kind)
|
||||
|
||||
try:
|
||||
manifest = load_manifest(kind, brand)
|
||||
except ContractError as err:
|
||||
problems.append(err)
|
||||
continue
|
||||
|
||||
if verify_assets:
|
||||
try:
|
||||
check_assets(manifest, brand, kind)
|
||||
except ContractError as err:
|
||||
problems.append(err)
|
||||
continue
|
||||
|
||||
manifests[kind] = manifest
|
||||
|
||||
try:
|
||||
resolved = check_config(manifest, site, site_file)
|
||||
except ContractError as err:
|
||||
problems.append(err)
|
||||
continue
|
||||
|
||||
for key, values in resolved.items():
|
||||
config[f"{kind}.{key}"] = values
|
||||
|
||||
if problems:
|
||||
raise _aggregate(problems)
|
||||
|
||||
return DeviceStack(
|
||||
registry=registry,
|
||||
manifests=manifests,
|
||||
config=config,
|
||||
site_path=site_file,
|
||||
)
|
||||
|
||||
|
||||
def _aggregate(problems: List[ContractError]) -> ContractError:
|
||||
"""把多个契约错误合并成一个可上报的错误。
|
||||
|
||||
只有单个问题时原样返回,让常见情形保持聚焦的消息。多个问题则合并,
|
||||
并保留每个问题各自的 detail 与修复提示。
|
||||
"""
|
||||
if len(problems) == 1:
|
||||
return problems[0]
|
||||
|
||||
blocks: List[str] = []
|
||||
for index, err in enumerate(problems, start=1):
|
||||
block = [f"({index}) {err.what}"]
|
||||
if err.detail:
|
||||
for line in str(err.detail).splitlines():
|
||||
block.append(f" {line}")
|
||||
if err.fix_hint:
|
||||
block.append(f" 怎么修: {err.fix_hint}")
|
||||
blocks.append("\n".join(block))
|
||||
|
||||
return ContractError(
|
||||
f"设备契约校验发现 {len(problems)} 类问题(已全部列出,可一次改完)",
|
||||
fix_hint=(
|
||||
"按下面每一项分别处理;改完后重新运行: "
|
||||
"ros2 run arm_interfaces device_check"
|
||||
),
|
||||
detail="\n\n".join(blocks),
|
||||
)
|
||||
|
||||
|
||||
def check_requirement(
|
||||
stack: DeviceStack, requirement_name: str
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""验证某个流程的能力需求是否被所选驱动满足。
|
||||
|
||||
返回已满足的能力,按类别分组,便于调用方记录该流程运行在什么之上。
|
||||
|
||||
Raises
|
||||
------
|
||||
CapabilityError
|
||||
缺少某项所需能力时抛出。消息会点出该能力、它映射到的接口、所选的
|
||||
品牌,以及该编辑的清单。
|
||||
"""
|
||||
requirement = REQUIREMENTS.get(requirement_name)
|
||||
if requirement is None:
|
||||
known = ", ".join(sorted(REQUIREMENTS))
|
||||
raise CapabilityError(
|
||||
f"未知的流程需求名 '{requirement_name}'",
|
||||
fix_hint=(
|
||||
f"可用的流程名: {known}。"
|
||||
"若需要新流程,先在 src/arm_interfaces/arm_interfaces/capabilities.py 的 "
|
||||
"REQUIREMENTS 里登记"
|
||||
),
|
||||
)
|
||||
|
||||
needed_by_kind = {
|
||||
"robots": requirement.robot,
|
||||
"grippers": requirement.gripper,
|
||||
"sensors": requirement.sensor,
|
||||
"cameras": requirement.camera,
|
||||
}
|
||||
|
||||
satisfied: Dict[str, Set[str]] = {}
|
||||
missing_blocks: List[str] = []
|
||||
|
||||
for kind, needed in needed_by_kind.items():
|
||||
if not needed:
|
||||
continue
|
||||
|
||||
manifest = stack.manifests.get(kind)
|
||||
if manifest is None:
|
||||
missing_blocks.append(
|
||||
"\n".join(
|
||||
[
|
||||
f" {kind}: 未在 devices/registry.yaml 中选型",
|
||||
f" 该流程需要: {', '.join(needed)}",
|
||||
" 怎么修: 在 devices/registry.yaml 里为这一类设备选一个品牌",
|
||||
]
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
have = stack.capabilities(kind)
|
||||
missing = [cap for cap in needed if cap not in have]
|
||||
satisfied[kind] = {cap for cap in needed if cap in have}
|
||||
|
||||
if missing:
|
||||
missing_blocks.append(_missing_capability_block(manifest, kind, missing, have))
|
||||
|
||||
if missing_blocks:
|
||||
raise CapabilityError(
|
||||
f"流程 '{requirement_name}'({requirement.summary})所需能力不满足",
|
||||
fix_hint=(
|
||||
"按下面每一项给出的说明处理:若是选错了品牌/型号,改 "
|
||||
"devices/registry.yaml 或 config/site.yaml 的型号;"
|
||||
"若该品牌确实做不到,需要换驱动或换硬件"
|
||||
),
|
||||
detail="\n".join(missing_blocks),
|
||||
)
|
||||
|
||||
return satisfied
|
||||
|
||||
|
||||
def _missing_capability_block(
|
||||
manifest: Manifest, kind: str, missing: Sequence[str], have: Set[str]
|
||||
) -> str:
|
||||
lines = [f" {kind}(品牌 '{manifest.id}')缺少 {len(missing)} 项能力:"]
|
||||
for cap in missing:
|
||||
lines.append(f" - {describe(cap)}")
|
||||
iface = ROS_INTERFACE.get(cap)
|
||||
if iface is not None:
|
||||
lines.append(
|
||||
f" 需要实现的接口: {iface.kind} "
|
||||
f"'{iface.name}' [{iface.type}]"
|
||||
)
|
||||
lines.append(f" 该品牌当前已声明: {', '.join(sorted(have)) or '(无)'}")
|
||||
lines.append(
|
||||
f" 怎么修: 编辑 {manifest.path} 的 capabilities;"
|
||||
"但只有驱动真的实现了才能写进去,否则运行时会失败"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,488 @@
|
||||
"""加载并校验某个品牌的 ``manifest.yaml``。
|
||||
|
||||
清单是品牌的自我介绍,回答四个问题:
|
||||
|
||||
1. **我是谁?** —— ``id``、``label``、``vendor``、``version``
|
||||
2. **磁盘上必须有哪些厂商物料?** —— ``assets``(会被检查,并记录必需的
|
||||
格式与命名约定,方便操作员照做)
|
||||
3. **我能做什么?** —— ``capabilities``(:mod:`arm_interfaces.capabilities`
|
||||
词表的子集)
|
||||
4. **我需要什么配置?** —— ``config_schema``,其中每项 ``site_path`` 都指向
|
||||
``config/site.yaml`` 里的位置
|
||||
|
||||
这里的校验刻意从严:清单声明了未知能力或格式错误的配置项,属于应当在启动
|
||||
时暴露的 bug,而不是运动中途才冒出来的运行时意外。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
from . import paths
|
||||
from .capabilities import ALL_CAPABILITIES, capabilities_for_kind
|
||||
from .errors import ManifestError, MissingAssetError
|
||||
from .registry import KIND_FOLDERS
|
||||
|
||||
#: 选型中的键(复数)-> 清单里 ``kind`` 的取值(单数)。
|
||||
KIND_SINGULAR: Dict[str, str] = {
|
||||
"robots": "robot",
|
||||
"grippers": "gripper",
|
||||
"sensors": "sensor",
|
||||
"cameras": "camera",
|
||||
"sim": "sim",
|
||||
}
|
||||
|
||||
#: 契约层知道如何检查的配置值类型。
|
||||
KNOWN_TYPES = {"str", "int", "float", "bool", "list", "dict"}
|
||||
|
||||
#: 清单中必须存在的顶层键。
|
||||
REQUIRED_KEYS = ("id", "label", "kind", "driver")
|
||||
|
||||
|
||||
class Asset(NamedTuple):
|
||||
"""清单期望磁盘上存在的一项厂商物料。"""
|
||||
|
||||
path: str
|
||||
required: bool
|
||||
what: str
|
||||
fmt: str
|
||||
naming: str
|
||||
where_used: str
|
||||
notes: str
|
||||
|
||||
|
||||
class DriverSpec(NamedTuple):
|
||||
"""如何启动该品牌的适配器。"""
|
||||
|
||||
package: str
|
||||
executable: str
|
||||
build: str
|
||||
notes: str
|
||||
|
||||
|
||||
class ConfigField(NamedTuple):
|
||||
"""一个已声明的配置键,指向 ``config/site.yaml`` 中的位置。"""
|
||||
|
||||
key: str
|
||||
site_path: str
|
||||
type: str
|
||||
required: bool
|
||||
default: Any
|
||||
unit: Optional[str]
|
||||
doc: str
|
||||
|
||||
|
||||
class Manifest(NamedTuple):
|
||||
"""已解析并校验的 ``manifest.yaml``。"""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
kind: str
|
||||
vendor: str
|
||||
version: str
|
||||
description: str
|
||||
driver: DriverSpec
|
||||
capabilities: Tuple[str, ...]
|
||||
assets: Tuple[Asset, ...]
|
||||
config_schema: Tuple[ConfigField, ...]
|
||||
models: Tuple[Dict[str, Any], ...]
|
||||
path: Path
|
||||
raw: Dict[str, Any]
|
||||
|
||||
def supports(self, capability: str) -> bool:
|
||||
"""该驱动是否声明了这项能力。"""
|
||||
return capability in self.capabilities
|
||||
|
||||
def model_capabilities(self, model_id: str) -> Optional[Tuple[str, ...]]:
|
||||
"""某个具体型号 id 的能力(前提是清单列出了型号)。
|
||||
|
||||
清单未逐一列举型号时返回 ``None``,表示顶层 ``capabilities`` 列表
|
||||
对全部型号都适用。
|
||||
"""
|
||||
for model in self.models:
|
||||
if model.get("id") == model_id:
|
||||
caps = model.get("capabilities")
|
||||
if caps is None:
|
||||
return None
|
||||
return tuple(caps)
|
||||
return None
|
||||
|
||||
|
||||
def manifest_path(kind: str, brand: str) -> Path:
|
||||
"""返回某个“类别/品牌”组合预期的清单路径。"""
|
||||
return paths.brand_dir(KIND_FOLDERS.get(kind, kind), brand) / "manifest.yaml"
|
||||
|
||||
|
||||
def load_manifest(kind: str, brand: str, path: Path | None = None) -> Manifest:
|
||||
"""读取、校验并返回某个“类别/品牌”组合的清单。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
kind:
|
||||
选型中的键 —— ``robots``、``grippers``、``sensors``、``cameras``、``sim``。
|
||||
brand:
|
||||
``devices/<kind>/`` 下的品牌目录名。
|
||||
"""
|
||||
target = path or manifest_path(kind, brand)
|
||||
|
||||
if not target.is_file():
|
||||
available = _available_brands(kind)
|
||||
raise ManifestError(
|
||||
f"品牌 '{brand}' 的清单文件不存在: {target}",
|
||||
fix_hint=(
|
||||
f"确认 devices/{KIND_FOLDERS.get(kind, kind)}/{brand}/manifest.yaml 存在;"
|
||||
f"或改 devices/registry.yaml 把 '{kind}' 指向一个已存在的品牌"
|
||||
),
|
||||
detail=(
|
||||
f"当前可用品牌: {', '.join(available) if available else '(无)'}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise ManifestError(
|
||||
f"{target} 不是合法的 YAML",
|
||||
fix_hint="检查缩进(不要用 Tab);多行文本要用 '>' 或 '|' 折叠",
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ManifestError(
|
||||
f"{target} 顶层必须是一个映射",
|
||||
fix_hint="manifest.yaml 应当是 'key: value' 结构",
|
||||
detail=f"实际类型: {type(data).__name__}",
|
||||
)
|
||||
|
||||
_check_required_keys(data, target)
|
||||
_check_kind_matches(data, kind, target)
|
||||
|
||||
expected_kind = KIND_SINGULAR.get(kind, kind)
|
||||
capabilities = _parse_capabilities(data, expected_kind, target)
|
||||
assets = _parse_assets(data, target)
|
||||
config_schema = _parse_config_schema(data, target)
|
||||
driver = _parse_driver(data, target)
|
||||
models = _parse_models(data, target)
|
||||
_check_model_capabilities(models, expected_kind, target)
|
||||
|
||||
return Manifest(
|
||||
id=str(data["id"]),
|
||||
label=str(data["label"]),
|
||||
kind=str(data["kind"]),
|
||||
vendor=str(data.get("vendor", "")),
|
||||
version=str(data.get("version", "")),
|
||||
description=str(data.get("description", "")).strip(),
|
||||
driver=driver,
|
||||
capabilities=capabilities,
|
||||
assets=assets,
|
||||
config_schema=config_schema,
|
||||
models=models,
|
||||
path=target,
|
||||
raw=data,
|
||||
)
|
||||
|
||||
|
||||
def check_assets(manifest: Manifest, brand: str, kind: str) -> None:
|
||||
"""校验每个 ``required`` 物料都存在,并在错误里给出格式提示。
|
||||
|
||||
这里只检查是否存在。格式与命名无法自动验证,因此在失败消息里回告给
|
||||
操作员,让照做成为可能。
|
||||
"""
|
||||
base = paths.brand_dir(KIND_FOLDERS.get(kind, kind), brand)
|
||||
missing: List[str] = []
|
||||
|
||||
for asset in manifest.assets:
|
||||
if not asset.required:
|
||||
continue
|
||||
target = base / asset.path
|
||||
if not _asset_present(target):
|
||||
missing.append(
|
||||
"\n".join(
|
||||
[
|
||||
f" 缺少: {asset.path}",
|
||||
f" 放什么: {asset.what or '(清单未说明)'}",
|
||||
f" 格式要求: {asset.fmt or '(清单未说明)'}",
|
||||
f" 命名要求: {asset.naming or '(清单未说明)'}",
|
||||
f" 放好之后: {asset.where_used or '(清单未说明)'}",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if missing:
|
||||
raise MissingAssetError(
|
||||
f"品牌 '{brand}' 的必需物料缺失({len(missing)} 项)",
|
||||
fix_hint=(
|
||||
f"把物料放进 devices/{KIND_FOLDERS.get(kind, kind)}/{brand}/ "
|
||||
f"下对应路径;具体要求见该目录的 README.md"
|
||||
),
|
||||
detail="\n".join(missing),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内部辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _available_brands(kind: str) -> List[str]:
|
||||
folder = paths.devices_root() / KIND_FOLDERS.get(kind, kind)
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(p.name for p in folder.iterdir() if p.is_dir() and not p.name.startswith("."))
|
||||
|
||||
|
||||
def _asset_present(target: Path) -> bool:
|
||||
"""物料是文件、目录,或指向二者的符号链接,都算作存在。
|
||||
|
||||
接受目录,是因为厂商常以整棵目录树(``rdk/``)交付,不应该逼着操作员
|
||||
在目录里额外放一个占位文件。
|
||||
"""
|
||||
if target.is_symlink():
|
||||
return target.exists()
|
||||
return target.exists()
|
||||
|
||||
|
||||
def _check_required_keys(data: Dict[str, Any], path: Path) -> None:
|
||||
missing = [k for k in REQUIRED_KEYS if not data.get(k)]
|
||||
if missing:
|
||||
raise ManifestError(
|
||||
f"{path} 缺少必需字段: {', '.join(missing)}",
|
||||
fix_hint=(
|
||||
"manifest.yaml 必须包含 id / label / kind / driver 四项;"
|
||||
"driver 下要有 package 与 executable"
|
||||
),
|
||||
detail=f"已读到的顶层键: {', '.join(sorted(data)) or '(无)'}",
|
||||
)
|
||||
|
||||
|
||||
def _check_kind_matches(data: Dict[str, Any], registry_kind: str, path: Path) -> None:
|
||||
expected = KIND_SINGULAR.get(registry_kind, registry_kind)
|
||||
actual = str(data.get("kind", ""))
|
||||
if actual != expected:
|
||||
raise ManifestError(
|
||||
f"{path} 的 kind 字段与所在目录不符",
|
||||
fix_hint=(
|
||||
f"把 'kind:' 改成 '{expected}'(devices/{KIND_FOLDERS.get(registry_kind, registry_kind)}/ "
|
||||
f"下的清单都必须是这个值)"
|
||||
),
|
||||
detail=f"实际写的 kind: '{actual}'",
|
||||
)
|
||||
|
||||
|
||||
def _parse_capabilities(
|
||||
data: Dict[str, Any], kind: str, path: Path
|
||||
) -> Tuple[str, ...]:
|
||||
raw = data.get("capabilities") or []
|
||||
if not isinstance(raw, list):
|
||||
raise ManifestError(
|
||||
f"{path} 的 capabilities 必须是列表",
|
||||
fix_hint="写成多行 '-' 列表,例如:\n capabilities:\n - open\n - close",
|
||||
detail=f"实际类型: {type(raw).__name__}",
|
||||
)
|
||||
|
||||
allowed = capabilities_for_kind(kind)
|
||||
unknown = [c for c in raw if c not in allowed]
|
||||
if unknown:
|
||||
known_code = [c for c in raw if c in allowed]
|
||||
raise ManifestError(
|
||||
f"{path} 声明了框架不认识的 {kind} 能力: {', '.join(unknown)}",
|
||||
fix_hint=(
|
||||
f"{kind} 能声明的能力只有: {', '.join(sorted(allowed))}。"
|
||||
"若确实需要新能力,先在 src/arm_interfaces/arm_interfaces/capabilities.py "
|
||||
"里登记它,再写进清单"
|
||||
),
|
||||
detail=f"该清单里合法的能力: {', '.join(known_code) or '(无)'}",
|
||||
)
|
||||
|
||||
# 同一项能力声明两次,几乎总是复制粘贴出的疏漏。
|
||||
duplicates = sorted({c for c in raw if raw.count(c) > 1})
|
||||
if duplicates:
|
||||
raise ManifestError(
|
||||
f"{path} 的能力声明有重复: {', '.join(duplicates)}",
|
||||
fix_hint="删掉重复项(每项只写一次)",
|
||||
)
|
||||
|
||||
return tuple(raw)
|
||||
|
||||
|
||||
def _parse_assets(data: Dict[str, Any], path: Path) -> Tuple[Asset, ...]:
|
||||
raw = data.get("assets") or []
|
||||
if not isinstance(raw, list):
|
||||
raise ManifestError(
|
||||
f"{path} 的 assets 必须是列表",
|
||||
fix_hint="写成多行 '-' 列表,每项含 path / required / what / format / naming",
|
||||
detail=f"实际类型: {type(raw).__name__}",
|
||||
)
|
||||
|
||||
assets: List[Asset] = []
|
||||
for index, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
raise ManifestError(
|
||||
f"{path} 的 assets 第 {index + 1} 项不是映射",
|
||||
fix_hint="每一项都要写成 'path: rdk' 这样的键值对",
|
||||
detail=f"实际值: {item!r}",
|
||||
)
|
||||
rel = item.get("path")
|
||||
if not rel or not isinstance(rel, str):
|
||||
raise ManifestError(
|
||||
f"{path} 的 assets 第 {index + 1} 项缺少 path",
|
||||
fix_hint="补上 'path:',值是相对于品牌目录的相对路径(如 'rdk')",
|
||||
)
|
||||
assets.append(
|
||||
Asset(
|
||||
path=rel.strip().rstrip("/"),
|
||||
required=bool(item.get("required", False)),
|
||||
what=str(item.get("what", "")).strip(),
|
||||
fmt=str(item.get("format", "")).strip(),
|
||||
naming=str(item.get("naming", "")).strip(),
|
||||
where_used=str(item.get("where_used", "")).strip(),
|
||||
notes=str(item.get("notes", "")).strip(),
|
||||
)
|
||||
)
|
||||
return tuple(assets)
|
||||
|
||||
|
||||
def _parse_config_schema(data: Dict[str, Any], path: Path) -> Tuple[ConfigField, ...]:
|
||||
raw = data.get("config_schema") or []
|
||||
if not isinstance(raw, list):
|
||||
raise ManifestError(
|
||||
f"{path} 的 config_schema 必须是列表",
|
||||
fix_hint="每项形如:\n - key: port\n site_path: gripper.port\n type: str\n required: true",
|
||||
detail=f"实际类型: {type(raw).__name__}",
|
||||
)
|
||||
|
||||
fields: List[ConfigField] = []
|
||||
seen: Dict[str, int] = {}
|
||||
for index, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
raise ManifestError(
|
||||
f"{path} 的 config_schema 第 {index + 1} 项不是映射",
|
||||
fix_hint="每项都要是 'key: ... / site_path: ...' 形式的键值对",
|
||||
detail=f"实际值: {item!r}",
|
||||
)
|
||||
|
||||
key = item.get("key")
|
||||
site_path = item.get("site_path")
|
||||
if not key or not isinstance(key, str):
|
||||
raise ManifestError(
|
||||
f"{path} 的 config_schema 第 {index + 1} 项缺少 key",
|
||||
fix_hint="补上 'key:',它是这项配置在清单内部的标识名",
|
||||
)
|
||||
if not site_path or not isinstance(site_path, str):
|
||||
raise ManifestError(
|
||||
f"{path} 的 config_schema 的 '{key}' 缺少 site_path",
|
||||
fix_hint=(
|
||||
f"补上 'site_path:',告诉框架去 config/site.yaml 的哪个键取值,"
|
||||
f"例如 'gripper.port' 或 'robot.robots[].gripper.port'"
|
||||
),
|
||||
)
|
||||
|
||||
if key in seen:
|
||||
raise ManifestError(
|
||||
f"{path} 的 config_schema 里 '{key}' 重复声明(第 {seen[key]} 项与第 {index + 1} 项)",
|
||||
fix_hint="同一 key 只能声明一次",
|
||||
)
|
||||
seen[key] = index + 1
|
||||
|
||||
value_type = str(item.get("type", "str"))
|
||||
if value_type not in KNOWN_TYPES:
|
||||
raise ManifestError(
|
||||
f"{path} 的 config_schema 的 '{key}' 用了未知类型 '{value_type}'",
|
||||
fix_hint=f"type 只能是: {', '.join(sorted(KNOWN_TYPES))}",
|
||||
)
|
||||
|
||||
required = bool(item.get("required", False))
|
||||
default = item.get("default")
|
||||
if required and default is not None:
|
||||
raise ManifestError(
|
||||
f"{path} 的 config_schema 的 '{key}' 同时要求必填又给了默认值",
|
||||
fix_hint=(
|
||||
"二者只能留一个:真必填就删掉 default;有合理默认值就把 required 改成 false"
|
||||
),
|
||||
)
|
||||
|
||||
unit = item.get("unit")
|
||||
fields.append(
|
||||
ConfigField(
|
||||
key=key,
|
||||
site_path=site_path.strip(),
|
||||
type=value_type,
|
||||
required=required,
|
||||
default=default,
|
||||
unit=str(unit).strip() if unit else None,
|
||||
doc=str(item.get("doc", "")).strip(),
|
||||
)
|
||||
)
|
||||
return tuple(fields)
|
||||
|
||||
|
||||
def _parse_driver(data: Dict[str, Any], path: Path) -> DriverSpec:
|
||||
raw = data.get("driver")
|
||||
if not isinstance(raw, dict):
|
||||
raise ManifestError(
|
||||
f"{path} 的 driver 必须是映射",
|
||||
fix_hint="写成:\n driver:\n package: arm_driver_flexiv\n executable: flexiv_arm_driver",
|
||||
detail=f"实际类型: {type(raw).__name__}",
|
||||
)
|
||||
package = raw.get("package")
|
||||
executable = raw.get("executable")
|
||||
if not package or not executable:
|
||||
raise ManifestError(
|
||||
f"{path} 的 driver 缺少 package 或 executable",
|
||||
fix_hint="两个都要写:package 是 ROS 包名,executable 是节点可执行名",
|
||||
)
|
||||
return DriverSpec(
|
||||
package=str(package),
|
||||
executable=str(executable),
|
||||
build=str(raw.get("build", "colcon")),
|
||||
notes=str(raw.get("notes", "")).strip(),
|
||||
)
|
||||
|
||||
|
||||
def _parse_models(data: Dict[str, Any], path: Path) -> Tuple[Dict[str, Any], ...]:
|
||||
raw = data.get("models") or []
|
||||
if not isinstance(raw, list):
|
||||
raise ManifestError(
|
||||
f"{path} 的 models 必须是列表",
|
||||
fix_hint="每个型号一项,形如:\n - id: d405\n label: D405 腕部相机",
|
||||
detail=f"实际类型: {type(raw).__name__}",
|
||||
)
|
||||
ids: List[str] = []
|
||||
for index, item in enumerate(raw):
|
||||
if not isinstance(item, dict) or not item.get("id"):
|
||||
raise ManifestError(
|
||||
f"{path} 的 models 第 {index + 1} 项缺少 id",
|
||||
fix_hint="每个型号都要有唯一的 'id:'(会被 config/site.yaml 引用)",
|
||||
detail=f"实际值: {item!r}",
|
||||
)
|
||||
ids.append(str(item["id"]))
|
||||
duplicates = sorted({i for i in ids if ids.count(i) > 1})
|
||||
if duplicates:
|
||||
raise ManifestError(
|
||||
f"{path} 的 models 有重复 id: {', '.join(duplicates)}",
|
||||
fix_hint="型号 id 必须唯一,因为它要被 site.yaml 引用",
|
||||
)
|
||||
return tuple(raw)
|
||||
|
||||
|
||||
def _check_model_capabilities(
|
||||
models: Tuple[Dict[str, Any], ...], kind: str, path: Path
|
||||
) -> None:
|
||||
allowed = capabilities_for_kind(kind)
|
||||
for model in models:
|
||||
caps = model.get("capabilities")
|
||||
if caps is None:
|
||||
continue
|
||||
if not isinstance(caps, list):
|
||||
raise ManifestError(
|
||||
f"{path} 的型号 '{model.get('id')}' 的 capabilities 必须是列表",
|
||||
fix_hint="写成多行 '-' 列表",
|
||||
)
|
||||
unknown = [c for c in caps if c not in allowed]
|
||||
if unknown:
|
||||
raise ManifestError(
|
||||
f"{path} 的型号 '{model.get('id')}' 声明了未知能力: {', '.join(unknown)}",
|
||||
fix_hint=f"{kind} 能声明的能力只有: {', '.join(sorted(allowed))}",
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""工作区路径的唯一权威来源。
|
||||
|
||||
旧代码库用三种不同方式解析工作区根目录:
|
||||
``arm_application/site_io.py``、``scripts/lib/export_site_env.py``,以及若干
|
||||
用各自 ``_site_path()`` 辅助函数直接读 ``site.yaml`` 的节点。
|
||||
它们并不总是一致。本模块是解析路径的唯一场所,其他所有地方
|
||||
——包括 ``site_io.py``——都应委托到这里。
|
||||
|
||||
解析顺序(第一个非空者胜出):
|
||||
|
||||
1. 显式环境变量覆盖(``SITE_ROS2_ROOT`` / ``SITE_CONFIG``)
|
||||
2. ``ROS2_WS``(由 ``scripts/env.sh`` 导出)
|
||||
3. ``~/ros2``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def ros2_root() -> Path:
|
||||
"""返回工作区根目录,并遵循环境变量覆盖。"""
|
||||
for key in ("SITE_ROS2_ROOT", "ROS2_WS"):
|
||||
value = os.environ.get(key, "").strip()
|
||||
if value:
|
||||
return Path(value).expanduser()
|
||||
return Path.home() / "ros2"
|
||||
|
||||
|
||||
def site_path() -> Path:
|
||||
"""返回主配置 ``config/site.yaml`` 的路径。"""
|
||||
override = os.environ.get("SITE_CONFIG", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return ros2_root() / "config" / "site.yaml"
|
||||
|
||||
|
||||
def devices_root() -> Path:
|
||||
"""返回设备目录树的根目录。"""
|
||||
override = os.environ.get("SITE_DEVICES_ROOT", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return ros2_root() / "devices"
|
||||
|
||||
|
||||
def registry_path() -> Path:
|
||||
"""返回设备选型文件 ``devices/registry.yaml`` 的路径。"""
|
||||
return devices_root() / "registry.yaml"
|
||||
|
||||
|
||||
def handeye_root() -> Path:
|
||||
"""返回手眼模块目录(``handeye/``)的根目录。"""
|
||||
return ros2_root() / "handeye"
|
||||
|
||||
|
||||
def brand_dir(kind: str, brand: str) -> Path:
|
||||
"""返回某类设备下某个品牌所在的目录。
|
||||
|
||||
``kind`` 是 ``devices/`` 中使用的复数目录名(``robots``、
|
||||
``grippers``、``sensors``、``cameras``、``sim``)。
|
||||
"""
|
||||
return devices_root() / kind / brand
|
||||
@@ -0,0 +1,128 @@
|
||||
"""加载并校验 ``devices/registry.yaml``。
|
||||
|
||||
选型文件只回答一个问题:*当前每一类设备由哪个品牌实现?* 按设计它不保存
|
||||
任何字段值——所有速度、阈值和序列号都在 ``config/site.yaml`` 里。把“选型”
|
||||
与“取值”分开,正是为了杜绝“改了值却毫无反应”这一类 bug。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, NamedTuple
|
||||
|
||||
import yaml
|
||||
|
||||
from . import paths
|
||||
from .errors import RegistryError
|
||||
|
||||
#: 设备类别 -> ``devices/`` 下存放其品牌的目录。
|
||||
#: 同时也是 ``registry.yaml`` 中使用的键。
|
||||
KIND_FOLDERS: Dict[str, str] = {
|
||||
"robots": "robots",
|
||||
"grippers": "grippers",
|
||||
"sensors": "sensors",
|
||||
"cameras": "cameras",
|
||||
"sim": "sim",
|
||||
}
|
||||
|
||||
#: 选型文件必须选择的每一类设备。``sim`` 是可选的(无头环境)。
|
||||
REQUIRED_KINDS = ("robots", "grippers", "sensors", "cameras")
|
||||
OPTIONAL_KINDS = ("sim",)
|
||||
|
||||
|
||||
class Registry(NamedTuple):
|
||||
"""解析后的 ``devices/registry.yaml``。"""
|
||||
|
||||
selections: Dict[str, str]
|
||||
"""kind -> 品牌 id,例如 ``{"robots": "flexiv"}``。"""
|
||||
|
||||
path: Path
|
||||
"""选型文件的加载来源(用于错误消息)。"""
|
||||
|
||||
raw: Dict[str, Any]
|
||||
"""完整解析结果,便于向前兼容。"""
|
||||
|
||||
def brand(self, kind: str) -> str:
|
||||
"""返回某类设备选中的品牌,未设置则抛错。"""
|
||||
try:
|
||||
return self.selections[kind]
|
||||
except KeyError:
|
||||
raise RegistryError(
|
||||
f"{self.path} 里没有为 '{kind}' 选择驱动",
|
||||
fix_hint=(
|
||||
f"编辑 devices/registry.yaml,把 '{kind}:' 设成 "
|
||||
f"devices/{KIND_FOLDERS[kind]}/ 下的目录名"
|
||||
),
|
||||
) from None
|
||||
|
||||
|
||||
def load_registry(path: Path | None = None) -> Registry:
|
||||
"""读取并校验选型文件。
|
||||
|
||||
Raises
|
||||
------
|
||||
RegistryError
|
||||
文件缺失、无法解析、顶层不是映射,或漏掉必需的设备类别时抛出。
|
||||
"""
|
||||
target = path or paths.registry_path()
|
||||
|
||||
if not target.is_file():
|
||||
raise RegistryError(
|
||||
f"找不到设备选型文件: {target}",
|
||||
fix_hint=(
|
||||
"确认 devices/registry.yaml 存在。若是新克隆的工作区,"
|
||||
"检查 devices/ 目录是否完整"
|
||||
),
|
||||
detail=f"期望路径: {target}",
|
||||
)
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(target.read_text(encoding="utf-8")) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise RegistryError(
|
||||
f"{target} 不是合法的 YAML",
|
||||
fix_hint="检查缩进(YAML 不允许用 Tab)与冒号后的空格",
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise RegistryError(
|
||||
f"{target} 顶层必须是一个映射(key: value)",
|
||||
fix_hint="文件开头应当形如 'robots: flexiv',而不是列表或纯文本",
|
||||
detail=f"实际读到的类型: {type(data).__name__}",
|
||||
)
|
||||
|
||||
selections: Dict[str, str] = {}
|
||||
for kind in (*REQUIRED_KINDS, *OPTIONAL_KINDS):
|
||||
value = data.get(kind)
|
||||
if value is None:
|
||||
if kind in OPTIONAL_KINDS:
|
||||
continue
|
||||
raise RegistryError(
|
||||
f"{target} 缺少 '{kind}' 选型",
|
||||
fix_hint=(
|
||||
f"在 devices/registry.yaml 里加一行 '{kind}: <品牌目录名>',"
|
||||
f"可选值为 devices/{KIND_FOLDERS[kind]}/ 下的目录名"
|
||||
),
|
||||
)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RegistryError(
|
||||
f"{target} 的 '{kind}' 必须是非空字符串",
|
||||
fix_hint=f"把 '{kind}:' 后面改成一个品牌目录名(不要写成列表)",
|
||||
detail=f"实际值: {value!r}",
|
||||
)
|
||||
selections[kind] = value.strip()
|
||||
|
||||
return Registry(selections=selections, path=target, raw=data)
|
||||
|
||||
|
||||
def list_available_brands(kind: str) -> list[str]:
|
||||
"""返回到磁盘上某类设备现存的品牌目录名。"""
|
||||
folder = paths.devices_root() / KIND_FOLDERS.get(kind, kind)
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
p.name
|
||||
for p in folder.iterdir()
|
||||
if p.is_dir() and not p.name.startswith(".")
|
||||
)
|
||||
Reference in New Issue
Block a user