别人克隆后没有 install,source 会直接失败。把展开后的 install 放进仓库,符号链接和指向本机 build 的 egg-link 收成真实文件,Python 入口改走当前环境的解释器,这样不用先 colcon build 也能启动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-15 11:04:43 +08:00
parent 3c86441fb0
commit 926a7f9796
1492 changed files with 1302154 additions and 2 deletions
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
# EASY-INSTALL-ENTRY-SCRIPT: 'arm-interfaces','console_scripts','device_check'
import re
import sys
# for compatibility with easy_install; see #2198
__requires__ = 'arm-interfaces'
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', 'console_scripts', 'device_check')())
@@ -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
@@ -0,0 +1,27 @@
package.xml
setup.cfg
setup.py
../../build/arm_interfaces/arm_interfaces.egg-info/PKG-INFO
../../build/arm_interfaces/arm_interfaces.egg-info/SOURCES.txt
../../build/arm_interfaces/arm_interfaces.egg-info/dependency_links.txt
../../build/arm_interfaces/arm_interfaces.egg-info/entry_points.txt
../../build/arm_interfaces/arm_interfaces.egg-info/requires.txt
../../build/arm_interfaces/arm_interfaces.egg-info/top_level.txt
../../build/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
arm_interfaces.egg-info/PKG-INFO
arm_interfaces.egg-info/SOURCES.txt
arm_interfaces.egg-info/dependency_links.txt
arm_interfaces.egg-info/entry_points.txt
arm_interfaces.egg-info/requires.txt
arm_interfaces.egg-info/top_level.txt
arm_interfaces.egg-info/zip-safe
resource/arm_interfaces
@@ -0,0 +1,2 @@
[console_scripts]
device_check = arm_interfaces.cli:main
@@ -0,0 +1,2 @@
setuptools
PyYAML
@@ -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",
]
@@ -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())
@@ -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(".")
)