352270d1cc
前一个提交给了四个单项判据(USB / V4L / RDK / 手眼)。这一批把它们的**判据入口**
补齐并编排成一条命令,产出「结论 + 要人工做的事」:
scripts/lib/real_preflight.py
run_all() 按**现场该处理的顺序**排出结论,不是按代码顺序:
现场配置 → 遗留进程 → 网络 → 机械臂 RDK → 腕部相机 → USB/触觉/几何
顺序是有理由的:遗留的 ros2_control 占着 RDK,会让后面每一项的现象都指向别处,
所以先查它;机械臂能不能动是唯一的人工硬阻断,排在相机之前。
新增 check_cameras —— **本臂配置的那只腕部相机在不在 USB 上**。
这是本次实测暴露的判据缺口:2026-09-28 现场 D405 整个从 lsusb 消失
(只剩顶置 D435),而旧体检**全绿** —— check_usb 只看协商速率,遍历的是
「发现到的」设备,对「这只根本不在了」无话可说。缺 D405 之后踩的是完全不同
的一类错误(点云为空 / 找不到工件),排查方向会被带偏很远。
按 site.yaml 的配置意图核对:robots[<sn>].camera.name → cameras.<name>.model,
比**型号**而不是序列号 —— 实测这台 D435 的 sysfs serial 是空串,按序列号判会
永远判「不在」;序列号只在型号对上时做一次「是不是本臂那只」的提醒(WARN)。
修掉三处会误导现场的输出:
· _run() 改成三返回值(分离 stdout/stderr,RDK 的 C++ 日志会混进 stdout)
之后,_ps_output/网络两处还在按两返回值解包 —— 体检**第一步就崩**,
什么结论都给不出来。现在有 test_run_all_completes_without_crashing 钉住。
· 人工动作里「去查网线」出现过两条(check_network 与 rdk_status 各写一句
意思相同、字面不同的话),去重是按整串文字做的,于是人读到两条、还得判断
「这是一个问题还是两个」。措辞抽到 manual_actions.py,两边字面一致。
· 触觉 wedged 的处置与 v4l_health 同步(免密的 reset 是首选,重插是兜底)。
scripts/lib/manual_actions.py(新)
人工动作措辞的**唯一**来源。判据分在两个文件、还跑在两个不同的 Python 上
(3.11 / 3.10,见 rdk_status 的模块说明),互相 import 不了,所以抽成只依赖
标准库的公共模块。
scripts/run_real_preflight.sh(新)
一条命令的入口:只读、不动臂,不需要机械臂能连上。
实测(2026-09-28,arm 89):体检跑通并给出 NO-GO + 7 条互不重复的人工动作,
新判据抓到「D405 不在 USB 上」(旧版全绿)。同时确认了本机可用的部分:
夹爪 PGEA 初始化完成 / 全开 20.00mm、右指触觉 SDK 300 帧 @29.9Hz、
手眼矩阵自洽、enp4s0 千兆全双工。
测试:test_real_preflight 28 项通过(含跨模块的措辞去重回归、
编排端到端冒烟、腕部相机四种情形)。
Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
6.8 KiB
Python
187 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
||
"""读一次机械臂的"能不能动"状态,并给出**人话结论**。
|
||
|
||
必须在 ``/home/cc/rdk_py310/bin/python`` 下跑(flexivrdk 与 ROS 的 3.11 ABI 不兼容)。
|
||
|
||
为什么要有这个脚本
|
||
------------------
|
||
``run_real.sh`` 起来之后,如果机器人没使能,``ros2_control`` 会**卡在**
|
||
``on_activate`` 里等 ``operational``(``wait_for_operational`` 是死循环重试)。
|
||
卡住的表现是:
|
||
|
||
· ``ros2 control list_controllers`` → ``Could not contact service``(不是"没起来")
|
||
· ``/joint_states`` 一条都不发
|
||
· 日志里每 5 秒一句 ``Waiting for operational (status: ...)``
|
||
|
||
实测(2026-09-28):状态是 ``IN_MANUAL_MODE`` —— 注意 ``robot.mode()`` 返回的是
|
||
``Mode.IDLE``,**看不出**这点,必须读 ``operational_status()``。示教器那个 Auto/Manual
|
||
开关没有 RDK 接口可以远程切(Flexiv 的安全设计),所以只能人到现场拨。
|
||
这个脚本的价值就是把"卡住了"翻译成"去拨那个开关",而不是让人去查 ROS。
|
||
|
||
输出
|
||
----
|
||
``--out FILE`` 写 ``KEY=VALUE``(RDK 自己的日志会混在 stdout 里,所以结果单独落文件,
|
||
不去解析第三方日志)。失败分类用 :func:`classify_state`(纯函数,可单测)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Sequence, Tuple
|
||
|
||
# rdk_status.py 必须在 **3.10** 下跑(flexivrdk 与 ROS 的 3.11 ABI 不兼容),
|
||
# 所以这里只 import 标准库 + 同目录下同样只用标准库的 manual_actions。
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
|
||
from manual_actions import ( # noqa: E402
|
||
NET_UNREACHABLE_ACTION,
|
||
RDK_REMOTE_MODE_ACTION,
|
||
RDK_STACK_HOLDING_ACTION,
|
||
)
|
||
|
||
# 结论级别:ok / warn / fail(与 scripts/lib/usb_health.py 同一套词)
|
||
LEVEL_OK = "ok"
|
||
LEVEL_WARN = "warn"
|
||
LEVEL_FAIL = "fail"
|
||
|
||
# 现场那句"必须人去拨"的说明。preflight 与文档引用同一句话。
|
||
MANUAL_HINT = (
|
||
"示教器切到 Auto(Remote)—— RDK **没有**远程切 Manual→Auto 的接口,"
|
||
"这是 Flexiv 的安全设计,必须人到现场拨。切完**不用**重启栈:"
|
||
"run_real.sh 里的 wait_for_operational 会自动接着 Enable。"
|
||
)
|
||
|
||
|
||
def classify_state(
|
||
*,
|
||
connected: bool,
|
||
operational: bool = False,
|
||
op_status: str = "",
|
||
mode: str = "",
|
||
fault: bool = False,
|
||
estop_released: bool = True,
|
||
error: str = "",
|
||
) -> Tuple[str, str, List[str]]:
|
||
"""把机器人状态翻成 ``(级别, 一句话结论, 建议动作)``。
|
||
|
||
判定顺序是**故意的**:故障/急停要排在模式前面 —— 急停没复位时模式可能还是
|
||
Auto,只报"模式不对"会让人白跑一趟。
|
||
"""
|
||
if not connected:
|
||
return (
|
||
LEVEL_FAIL,
|
||
f"连不上 RDK:{error or '未知原因'}",
|
||
[
|
||
# 措辞来自 manual_actions:体检报告里 check_network 也会给出
|
||
# 同一条,两处字面一致才能去重成一条(否则人读两遍)。
|
||
NET_UNREACHABLE_ACTION,
|
||
RDK_REMOTE_MODE_ACTION,
|
||
RDK_STACK_HOLDING_ACTION,
|
||
],
|
||
)
|
||
if fault:
|
||
return (
|
||
LEVEL_FAIL,
|
||
"机器人有故障(fault)",
|
||
[
|
||
"在示教器上清故障;没有示教器就重启控制柜。",
|
||
"栈里的 wait_for_operational 也会自动 ClearFault 重试。",
|
||
],
|
||
)
|
||
if not estop_released:
|
||
return (
|
||
LEVEL_FAIL,
|
||
"急停没复位(estop_released=False)",
|
||
["松开示教器/控制柜上的急停按钮,再按面板上的复位。"],
|
||
)
|
||
upper = (op_status or "").upper()
|
||
if "MANUAL" in upper:
|
||
return (
|
||
LEVEL_FAIL,
|
||
f"示教器在 Manual 模式(operational_status={op_status})—— "
|
||
"机器人不会使能,整条流程会一直等着",
|
||
[MANUAL_HINT],
|
||
)
|
||
if operational:
|
||
return (LEVEL_OK, f"已使能、可接收指令(mode={mode})", [])
|
||
return (
|
||
LEVEL_FAIL,
|
||
f"未使能(operational_status={op_status or '?'}, mode={mode or '?'})",
|
||
[
|
||
MANUAL_HINT,
|
||
"确认没有未清的故障、急停已复位。",
|
||
],
|
||
)
|
||
|
||
|
||
def _probe(sn: str, iface: str, enable: bool) -> Dict[str, object]:
|
||
"""连一次机器人并读状态。返回给 classify_state 的关键字参数。"""
|
||
import flexivrdk # 延迟导入:让本模块在没有 flexivrdk 的环境里也能被单测导入
|
||
|
||
out: Dict[str, object] = {
|
||
"connected": False,
|
||
"operational": False,
|
||
"op_status": "",
|
||
"mode": "",
|
||
"fault": False,
|
||
"estop_released": True,
|
||
"error": "",
|
||
}
|
||
try:
|
||
if iface:
|
||
robot = flexivrdk.Robot(sn, [iface], True, False)
|
||
else:
|
||
robot = flexivrdk.Robot(sn)
|
||
except Exception as exc: # noqa: BLE001
|
||
out["error"] = str(exc)
|
||
return out
|
||
|
||
out["connected"] = True
|
||
if enable:
|
||
try:
|
||
robot.Enable()
|
||
except Exception as exc: # noqa: BLE001
|
||
out["error"] = f"Enable 失败: {exc}"
|
||
|
||
def _safe(fn, default):
|
||
try:
|
||
return fn()
|
||
except Exception: # noqa: BLE001
|
||
return default
|
||
|
||
out["mode"] = str(_safe(robot.mode, ""))
|
||
out["operational"] = bool(_safe(robot.operational, False))
|
||
out["op_status"] = str(_safe(robot.operational_status, ""))
|
||
out["fault"] = bool(_safe(robot.fault, False))
|
||
out["estop_released"] = bool(_safe(robot.estop_released, True))
|
||
return out
|
||
|
||
|
||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||
ap = argparse.ArgumentParser(description="读一次机械臂状态并给结论")
|
||
ap.add_argument("--sn", required=True)
|
||
ap.add_argument("--iface", default="")
|
||
ap.add_argument("--enable", action="store_true", help="顺手尝试 Enable(会真的使能)")
|
||
ap.add_argument("--out", default="", help="把 KEY=VALUE 写到这个文件")
|
||
ap.add_argument("--json", action="store_true", help="把结果打到 stdout(JSON)")
|
||
args = ap.parse_args(argv)
|
||
|
||
state = _probe(args.sn, args.iface, args.enable)
|
||
level, summary, actions = classify_state(**state) # type: ignore[arg-type]
|
||
payload = {**state, "level": level, "summary": summary, "actions": actions}
|
||
|
||
if args.out:
|
||
lines = [f"{k}={v}" for k, v in payload.items() if k != "actions"]
|
||
lines.append("actions=" + " | ".join(actions))
|
||
Path(args.out).write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||
if args.json:
|
||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||
return 0 if level == LEVEL_OK else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|