克隆后还缺仿真安装树和两边的编译目录。把 build、build_sim、install_sim 一并放进仓库;仿真入口不再写死本机 conda,build 里指向源码的链接改成相对路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-15 11:07:04 +08:00
parent 926a7f9796
commit 80983954a4
5804 changed files with 2640781 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
../../src/arm_interfaces/arm_interfaces
@@ -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 @@
arm_interfaces
@@ -0,0 +1 @@
@@ -0,0 +1,77 @@
"""Device contract layer for the robotic arm stack.
Public entry points::
from arm_interfaces import load_device_stack, check_requirement
stack = load_device_stack() # raises ContractError with a fix hint
check_requirement(stack, "handeye") # raises CapabilityError if unmet
Everything device-selection related funnels through here so that the rest of the
framework never reads ``devices/`` or guesses at ``site.yaml`` structure itself.
"""
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__ = [
# loading
"load_device_stack",
"load_registry",
"load_manifest",
"load_site",
"check_config",
"check_assets",
"check_requirement",
"resolve_path",
"effective_capabilities",
"list_available_brands",
"manifest_path",
# types
"DeviceStack",
"Manifest",
"Registry",
# capabilities
"ALL_CAPABILITIES",
"CAPABILITIES_BY_KIND",
"REQUIREMENTS",
"ROS_INTERFACE",
"capabilities_for_kind",
"describe",
# errors
"ContractError",
"RegistryError",
"ManifestError",
"MissingAssetError",
"MissingConfigError",
"CapabilityError",
"BadConfigValueError",
"format_error",
]
@@ -0,0 +1,273 @@
"""Capability vocabulary and the requirement sets upper layers depend on.
Two distinct ideas live here, and keeping them separate matters:
``Capability``
What a *driver* can do. Declared by each brand's ``manifest.yaml``. Adding a
new brand never changes this file — only edit it when there is genuinely a
new kind of motion or sensing to express.
``Requirement``
What an *upper layer* needs. A flow (tracking, hand-eye calibration, the
through-gauge assembly state machine) declares the set of capabilities it
will call. The contract layer checks the selected drivers satisfy it at
startup, so a missing capability fails immediately with a clear message
instead of failing mid-motion.
The ``ROS_INTERFACE`` table maps each capability to the concrete ROS 2 endpoint
that implements it. It exists so error messages can name the interface an
operator should check, and so future drivers know exactly what to publish
or serve.
"""
from __future__ import annotations
from typing import Dict, FrozenSet, NamedTuple, Tuple
class CapabilityInfo(NamedTuple):
"""Documentation for one capability."""
owner: str
"""Which device kind provides it: robot | gripper | sensor | camera."""
summary: str
"""One-line description of what the capability does."""
# ---------------------------------------------------------------------------
# Robot capabilities
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
GRIPPER_CAPABILITIES: Dict[str, CapabilityInfo] = {
"open": CapabilityInfo("gripper", "张开。"),
"close": CapabilityInfo("gripper", "夹紧(可带力与速度参数)。"),
"move": CapabilityInfo("gripper", "移动到指定开度。"),
"rotate": CapabilityInfo(
"gripper", "旋转到指定角度。通止规装配 S08 循环的核心原语。"
),
"get_status": CapabilityInfo("gripper", "读开度、力、是否夹持到位。"),
}
# ---------------------------------------------------------------------------
# Sensor capabilities
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
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", "设置曝光。跟踪需要限制曝光避免运动拖影。"),
}
#: Every capability name known to the framework, tagged with its device kind.
ALL_CAPABILITIES: Dict[str, CapabilityInfo] = {
**ROBOT_CAPABILITIES,
**GRIPPER_CAPABILITIES,
**SENSOR_CAPABILITIES,
**CAMERA_CAPABILITIES,
}
#: Device kind -> the capability names that kind is allowed to declare.
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 interface mapping
# ---------------------------------------------------------------------------
class RosInterface(NamedTuple):
"""The concrete ROS 2 endpoint implementing a capability."""
namespace: str
name: str
kind: str # topic | service | action
type: str
#: capability -> ROS 2 endpoint. Names are relative to the device namespace and
#: are resolved per-instance (two arms never share a namespace).
ROS_INTERFACE: Dict[str, RosInterface] = {
# --- robot ---
"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"),
# --- gripper ---
"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"),
# --- sensor ---
"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"),
# --- camera ---
"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"),
}
# ---------------------------------------------------------------------------
# Requirement sets
# ---------------------------------------------------------------------------
class Requirement(NamedTuple):
"""A named flow and the capabilities it needs, grouped by device kind."""
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 the set of capability names valid for a device kind."""
return CAPABILITIES_BY_KIND.get(kind, frozenset())
def describe(capability: str) -> str:
"""Human-readable description used in error messages."""
info = ALL_CAPABILITIES.get(capability)
if info is None:
return f"{capability}(框架未登记的能力名)"
return f"{capability} —— {info.summary}"
@@ -0,0 +1,176 @@
"""``device_check`` — validate the device stack from the command line.
Usage::
ros2 run arm_interfaces device_check # load everything, print summary
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 # skip vendor asset checks
Exit codes are meaningful so scripts can branch on them:
=== ==========================================================
0 contract satisfied
2 contract violation (missing brand, config, asset, capability)
3 contract layer itself broke (unexpected exception)
=== ==========================================================
"""
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 - the CLI is the last line of defence
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,246 @@
"""Validate ``config/site.yaml`` against a manifest's declared ``config_schema``.
Two classes of bug motivated this module:
1. **Silent no-ops.** ``run_handeye.sh`` passed five parameters
(``settle_qd_max``, ``pin_holds``, ...) to a node that never declared them.
YAML accepted the values, nothing read them, tuning had no effect.
2. **Unit confusion.** A value typed in millimetres but interpreted as metres
produces a plausible-looking but wrong motion.
The guard addresses both: every declared field is checked for presence and type,
and ``unit`` is echoed in error messages so the operator sees the expected unit
next to the value they wrote.
``site_path`` supports a list-expansion token so per-instance config can be
declared once: ``robot.robots[].ip`` resolves to ``robot.robots[0].ip`` and
``robot.robots[1].ip`` and requires *every* instance to supply the value.
"""
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 types accepted for each declared type name. ``bool`` is deliberately
#: excluded from the numeric types: in YAML ``true`` is a bool, and silently
#: using it as 1.0 has caused real bugs.
_TYPE_CHECKS = {
"str": (str,),
"int": (int,),
"float": (int, float),
"bool": (bool,),
"list": (list,),
"dict": (dict,),
}
class ResolvedValue(NamedTuple):
"""One concrete value found in ``site.yaml`` for a declared field."""
path: str
"""Concrete path, e.g. ``robot.robots[1].ip``."""
value: Any
def load_site(path: Path | None = None) -> Dict[str, Any]:
"""Read ``config/site.yaml``.
Returns an empty dict when the file is absent, matching the lenient
behaviour the old ``site_io.load_site_dict`` had — absence is reported by
the guard as missing required config rather than as a parse crash.
"""
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]:
"""Resolve a (possibly list-expanding) dotted path against parsed YAML.
The ``[]`` token expands a collection into one entry per member, so
per-instance config can be declared once and still require *every* instance
to supply it:
* sequence — ``robot.robots[].sn`` → ``robot.robots[0].sn``, ``...[1].sn``
* mapping — ``cameras[].serial`` → ``cameras.d405_left.serial``, ...
Mappings are supported because ``site.yaml`` registers cameras (and other
named devices) as mappings keyed by device name rather than as sequences.
Missing branches produce no results; the caller decides whether that is an
error. A *scalar* where a collection is expected (``cameras: 5``) is
reported as a single value resolved at the collection level, so the
type check can flag it instead of the operator being told, misleadingly,
that the value is simply absent.
"""
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]:
"""Recursive worker for :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 []
# Scalar where a collection was declared. Stop here and let the type check
# produce a precise complaint rather than a misleading "missing".
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]]:
"""Validate all declared fields of a manifest against parsed site config.
Returns a mapping of ``field.key`` to the resolved values, so callers that
need the values anyway do not have to resolve them a second time.
Raises
------
MissingConfigError
A required field has no value in ``site.yaml``.
BadConfigValueError
A field is present but has the wrong YAML type.
"""
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
# A required field with per-instance expansion needs *every* instance
# populated. Reporting only when all were blank would let one
# unconfigured arm slip through.
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:
"""Whether a value should be treated as 'not supplied'."""
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:
# Unknown declared type is rejected at manifest load time.
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:
"""Render one missing-config problem.
``blank_paths`` lists concrete per-instance paths that were present but
empty (e.g. ``robot.robots[1].sn``), which is far more actionable than
repeating the abstract declaration path.
"""
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,82 @@
"""Structured contract errors.
Every error carries a ``fix_hint`` that names the exact file and key a human
should edit. This is deliberate: the whole point of the contract layer is to
replace "something silently did nothing" (the old behaviour, where
``run_handeye.sh`` passed five parameters the node never declared) with an
actionable message.
Callers should format errors with :func:`format_error`, which renders the
hint on its own indented line so it is impossible to miss in a terminal.
"""
from __future__ import annotations
from typing import Optional
class ContractError(Exception):
"""Base class for every device-contract failure.
Attributes
----------
what:
One-line description of what is wrong.
fix_hint:
Concrete instruction naming the file and key to edit. ``None`` means
the caller could not determine a remedy, which callers should treat as
a bug in the contract layer rather than user error.
detail:
Optional extra context (paths tried, values found, ...).
"""
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 - exercised via format_error
return format_error(self)
class RegistryError(ContractError):
"""``devices/registry.yaml`` is missing, unreadable, or incomplete."""
class ManifestError(ContractError):
"""A per-brand ``manifest.yaml`` is missing, unreadable, or malformed."""
class MissingAssetError(ContractError):
"""A vendor asset declared as required in a manifest is not on disk."""
class MissingConfigError(ContractError):
"""``config/site.yaml`` does not supply a required declared config key."""
class CapabilityError(ContractError):
"""An upper layer asked for a capability the selected driver does not declare."""
class BadConfigValueError(ContractError):
"""A config key exists but its value has the wrong type or shape."""
def format_error(err: ContractError) -> str:
"""Render a contract error as a multi-line, human-actionable message."""
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,279 @@
"""Load the whole device stack and check flows against it.
This is the single entry point the rest of the framework uses. It chains:
1. :mod:`arm_interfaces.registry` — which brand implements each kind
2. :mod:`arm_interfaces.manifest` — that brand's capabilities, assets, config
3. :mod:`arm_interfaces.config_guard` — does ``site.yaml`` supply that config
then exposes :func:`check_requirement`, which verifies that a named flow
(tracking, hand-eye, gauge assembly, ...) only asks for capabilities the
selected drivers actually declare.
The payoff is at startup: instead of discovering on the first motion that the
selected gripper cannot rotate, the run refuses to start and names the missing
capability, the interface it implies, and which manifest to change.
"""
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):
"""Everything about the currently selected hardware."""
registry: Registry
manifests: Dict[str, Manifest]
"""Registry kind -> loaded manifest, e.g. ``{"robots": <Manifest>}``."""
config: Dict[str, List[ResolvedValue]]
"""``f"{kind}.{field.key}"`` -> resolved site.yaml values."""
site_path: Path
"""The ``config/site.yaml`` that was validated."""
def manifest(self, kind: str) -> Manifest:
"""Return the manifest for a registry kind."""
return self.manifests[kind]
def capabilities(self, kind: str) -> Set[str]:
"""Effective capabilities of the selected driver for a kind."""
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]:
"""Model ids selected in site.yaml for a kind (may be empty)."""
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 a driver provides, given which of its models are selected.
Top-level ``capabilities`` are what *every* model of the brand supports.
A model may add more (RGI adds ``rotate`` on top of the shared open/close).
Declared capabilities are never removed, because narrowing silently would
turn a clear startup error into a confusing runtime failure.
"""
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:
"""Load and validate the entire device stack.
Validation failures across *all* device kinds are collected and reported
together. Reporting only the first one would force the operator to re-run
the check once per missing key, which is exactly the kind of round-trip this
layer exists to eliminate.
Raises
------
ContractError subclass
On contract violations, with actionable fix hints. When more than one
kind fails, a single aggregated :class:`ContractError` is raised whose
detail lists every problem.
"""
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"):
# A kind that was not selected at all is optional unless required.
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:
"""Combine several contract errors into one reportable error.
A single problem is returned unchanged so the common case keeps its focused
message. Multiple problems are merged, preserving each one's own detail and
fix hint.
"""
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]]:
"""Verify a flow's capability needs are met by the selected drivers.
Returns the capabilities that were satisfied, grouped by kind, so callers
can log what the flow is running against.
Raises
------
CapabilityError
When a needed capability is missing. The message names the capability,
the interface it maps to, the selected brand, and the manifest to edit.
"""
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,490 @@
"""Load and validate a brand's ``manifest.yaml``.
A manifest is a brand's self-description. It answers four questions:
1. **Who am I?** — ``id``, ``label``, ``vendor``, ``version``
2. **What vendor material must be on disk?** — ``assets`` (checked, with the
required format and naming convention recorded so operators can comply)
3. **What can I do?** — ``capabilities`` (subset of the vocabulary in
:mod:`arm_interfaces.capabilities`)
4. **What config do I need?** — ``config_schema``, whose ``site_path`` entries
point into ``config/site.yaml``
Validation here is intentionally strict: a manifest that declares an unknown
capability or a malformed config entry is a bug that should surface at startup,
not a runtime surprise mid-motion.
"""
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
#: registry key (plural) -> manifest ``kind`` value (singular).
KIND_SINGULAR: Dict[str, str] = {
"robots": "robot",
"grippers": "gripper",
"sensors": "sensor",
"cameras": "camera",
"sim": "sim",
}
#: Config value types the contract layer knows how to check.
KNOWN_TYPES = {"str", "int", "float", "bool", "list", "dict"}
#: Manifest top-level keys that must be present.
REQUIRED_KEYS = ("id", "label", "kind", "driver")
class Asset(NamedTuple):
"""One piece of vendor material a manifest expects on disk."""
path: str
required: bool
what: str
fmt: str
naming: str
where_used: str
notes: str
class DriverSpec(NamedTuple):
"""How to start this brand's adapter."""
package: str
executable: str
build: str
notes: str
class ConfigField(NamedTuple):
"""One declared config key, pointing into ``config/site.yaml``."""
key: str
site_path: str
type: str
required: bool
default: Any
unit: Optional[str]
doc: str
class Manifest(NamedTuple):
"""Parsed and validated ``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:
"""Whether this driver declares the capability."""
return capability in self.capabilities
def model_capabilities(self, model_id: str) -> Optional[Tuple[str, ...]]:
"""Capabilities for a specific model id, if the manifest lists models.
Returns ``None`` when the manifest does not enumerate models, which
means the top-level ``capabilities`` list applies to all of them.
"""
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 the expected manifest path for a kind/brand pair."""
return paths.brand_dir(KIND_FOLDERS.get(kind, kind), brand) / "manifest.yaml"
def load_manifest(kind: str, brand: str, path: Path | None = None) -> Manifest:
"""Read, validate and return the manifest for a kind/brand pair.
Parameters
----------
kind:
Registry key — ``robots``, ``grippers``, ``sensors``, ``cameras``, ``sim``.
brand:
Brand folder name under ``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:
"""Verify every ``required`` asset exists, with format hints in the error.
Only presence is checked. Format and naming cannot be verified
automatically, so they are reported back to the operator in the failure
message to make compliance possible.
"""
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),
)
# ---------------------------------------------------------------------------
# internal helpers
# ---------------------------------------------------------------------------
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:
"""An asset counts as present if it is a file, a dir, or a symlink to either.
A directory is accepted because vendors often ship a tree (``rdk/``) and the
operator should not be forced to add a sentinel file inside it.
"""
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 '(无)'}",
)
# A capability declared twice is almost always a copy-paste slip.
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,71 @@
"""Single source of truth for workspace paths.
The old codebase resolved the workspace root in three different ways:
``arm_application/site_io.py``, ``scripts/lib/export_site_env.py``, and several
nodes that read ``site.yaml`` directly with their own ``_site_path()`` helpers.
They did not always agree. This module is the one place that resolves paths, and
everything else — including ``site_io.py`` — should delegate here.
Resolution order (first non-empty wins):
1. Explicit environment override (``SITE_ROS2_ROOT`` / ``SITE_CONFIG``)
2. ``ROS2_WS`` (exported by ``scripts/env.sh``)
3. ``~/ros2``
"""
from __future__ import annotations
import os
from pathlib import Path
def ros2_root() -> Path:
"""Return the workspace root, honouring environment overrides."""
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:
"""Return the path to the master config ``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:
"""Return the root of the device folder tree."""
override = os.environ.get("SITE_DEVICES_ROOT", "").strip()
if override:
return Path(override).expanduser()
return ros2_root() / "devices"
def registry_path() -> Path:
"""Return the path to the device selection file ``devices/registry.yaml``."""
return devices_root() / "registry.yaml"
def assets_root() -> Path:
"""Return the root of the model-weight asset tree (``assets/``)."""
override = os.environ.get("SITE_ASSETS_ROOT", "").strip()
if override:
return Path(override).expanduser()
return ros2_root() / "assets"
def handeye_root() -> Path:
"""Return the root of the hand-eye module directory (``handeye/``)."""
return ros2_root() / "handeye"
def brand_dir(kind: str, brand: str) -> Path:
"""Return the directory holding one brand under a device kind.
``kind`` is the plural folder name used in ``devices/`` (``robots``,
``grippers``, ``sensors``, ``cameras``, ``sim``).
"""
return devices_root() / kind / brand
@@ -0,0 +1,130 @@
"""Load and validate ``devices/registry.yaml``.
The registry answers exactly one question: *which brand implements each device
kind right now?* It holds no field values by design — every speed, threshold and
serial number lives in ``config/site.yaml``. Keeping selection and values apart
is what stops the "I changed the value but nothing happened" class of bug.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, NamedTuple
import yaml
from . import paths
from .errors import RegistryError
#: Device kind -> the folder under ``devices/`` holding its brands.
#: Also the key used in ``registry.yaml``.
KIND_FOLDERS: Dict[str, str] = {
"robots": "robots",
"grippers": "grippers",
"sensors": "sensors",
"cameras": "cameras",
"sim": "sim",
}
#: Every kind the registry must select. ``sim`` is optional (headless setups).
REQUIRED_KINDS = ("robots", "grippers", "sensors", "cameras")
OPTIONAL_KINDS = ("sim",)
class Registry(NamedTuple):
"""Parsed ``devices/registry.yaml``."""
selections: Dict[str, str]
"""kind -> brand id, e.g. ``{"robots": "flexiv"}``."""
path: Path
"""Where the registry was loaded from (for error messages)."""
raw: Dict[str, Any]
"""Full parsed document, for forward compatibility."""
def brand(self, kind: str) -> str:
"""Return the selected brand for a kind, raising if unset."""
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:
"""Read and validate the registry file.
Raises
------
RegistryError
If the file is missing, unparseable, is not a mapping, or omits a
required kind.
"""
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]:
"""Return brand folder names present on disk for a device kind."""
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(".")
)
+1
View File
@@ -0,0 +1 @@
0
@@ -0,0 +1 @@
# generated from colcon_core/shell/template/command_prefix.sh.em
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
../../src/arm_interfaces/package.xml
@@ -0,0 +1,4 @@
import sys
if sys.prefix == '/home/cc/anaconda3/envs/ros_flexiv':
sys.real_prefix = sys.prefix
sys.prefix = sys.exec_prefix = '/home/cc/ros2/install/arm_interfaces'
+1
View File
@@ -0,0 +1 @@
../../../src/arm_interfaces/resource/arm_interfaces
+1
View File
@@ -0,0 +1 @@
../../src/arm_interfaces/setup.cfg
+1
View File
@@ -0,0 +1 @@
../../src/arm_interfaces/setup.py
@@ -0,0 +1 @@
prepend-non-duplicate;PYTHONPATH;/home/cc/ros2/build/arm_interfaces
@@ -0,0 +1,3 @@
# generated from colcon_powershell/shell/template/hook_prepend_value.ps1.em
colcon_prepend_unique_value PYTHONPATH "$env:COLCON_CURRENT_PREFIX//home/cc/ros2/build/arm_interfaces"
@@ -0,0 +1,3 @@
# generated from colcon_core/shell/template/hook_prepend_value.sh.em
_colcon_prepend_unique_value PYTHONPATH "/home/cc/ros2/build/arm_interfaces"