真机开工体检的编排:把「不报错但不对」的故障源排成现场能照着做的顺序
前一个提交给了四个单项判据(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>
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""人工动作的**唯一措辞**来源 —— 让同一件事在体检报告里只出现一次。
|
||||||
|
|
||||||
|
为什么单独一个文件
|
||||||
|
------------------
|
||||||
|
体检报告最后会汇总"要人工做的事",而去重是**按整串文字**做的
|
||||||
|
(见 ``real_preflight.Report.manual_actions``)。同一件事如果两处各写一句
|
||||||
|
意思相同、字面不同的话,去重就形同虚设 —— 现场看到的是:
|
||||||
|
|
||||||
|
1. 机器人不在网里:确认网线接控制柜 User Port、PC 与臂同网段,
|
||||||
|
先跑 bash scripts/check_robot_network.sh(当前目标 192.168.2.101)
|
||||||
|
...
|
||||||
|
3. 确认网线接的是控制柜用户口、PC 与机器人同网段
|
||||||
|
(先跑 bash scripts/check_robot_network.sh)。
|
||||||
|
|
||||||
|
两条说的是同一件事,人却要读两遍、还会怀疑"是不是两个不同的问题"。
|
||||||
|
真正的根因(`check_network` 判据 + `rdk_status` 的状态分类)分在两个文件里、
|
||||||
|
还跑在两个不同的 Python 上(3.11 / 3.10,见 rdk_status 的模块说明),
|
||||||
|
互相 import 不了,所以把措辞抽到这里:只依赖标准库,谁都能 import。
|
||||||
|
|
||||||
|
改这里的文字等于改现场看到的操作步骤,动之前先想清楚。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# 机器人不在网里(ping 不通)。措辞里**不带具体 IP** —— IP 已经在上面的
|
||||||
|
# 判据明细里打过了;带上 IP 就会让 check_network 和 rdk_status 的句子不一样,
|
||||||
|
# 去重立刻失效。
|
||||||
|
NET_UNREACHABLE_ACTION = (
|
||||||
|
"确认网线接的是控制柜用户口、PC 与机器人同网段"
|
||||||
|
"(先跑 bash scripts/check_robot_network.sh)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 通了网也连不上 RDK 时的三种常见原因(与厂商报错 a) b) d) 对齐)。
|
||||||
|
RDK_REMOTE_MODE_ACTION = "确认示教器上已开启 Remote/Ethernet(RDK)。"
|
||||||
|
RDK_STACK_HOLDING_ACTION = (
|
||||||
|
"如果 ros2_control 已经在跑,RDK 可能被它占着 —— 先停掉栈再试。"
|
||||||
|
)
|
||||||
@@ -32,6 +32,16 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Sequence, Tuple
|
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 同一套词)
|
# 结论级别:ok / warn / fail(与 scripts/lib/usb_health.py 同一套词)
|
||||||
LEVEL_OK = "ok"
|
LEVEL_OK = "ok"
|
||||||
LEVEL_WARN = "warn"
|
LEVEL_WARN = "warn"
|
||||||
@@ -65,10 +75,11 @@ def classify_state(
|
|||||||
LEVEL_FAIL,
|
LEVEL_FAIL,
|
||||||
f"连不上 RDK:{error or '未知原因'}",
|
f"连不上 RDK:{error or '未知原因'}",
|
||||||
[
|
[
|
||||||
"确认网线接的是控制柜用户口、PC 与机器人同网段(先跑 "
|
# 措辞来自 manual_actions:体检报告里 check_network 也会给出
|
||||||
"bash scripts/check_robot_network.sh)。",
|
# 同一条,两处字面一致才能去重成一条(否则人读两遍)。
|
||||||
"确认示教器上已开启 Remote/Ethernet(RDK)。",
|
NET_UNREACHABLE_ACTION,
|
||||||
"如果 ros2_control 已经在跑,RDK 可能被它占着 —— 先停掉栈再试。",
|
RDK_REMOTE_MODE_ACTION,
|
||||||
|
RDK_STACK_HOLDING_ACTION,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
if fault:
|
if fault:
|
||||||
|
|||||||
@@ -0,0 +1,722 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""真机开工前的**一次性体检**:把「看起来都起来了,其实动不了」的原因一次说清。
|
||||||
|
|
||||||
|
为什么必须有这个
|
||||||
|
----------------
|
||||||
|
2026-09-28 无人值守实测,一次开工踩了三个**互不相干、现象却很像**的坑:
|
||||||
|
|
||||||
|
1. 示教器还在 Manual 模式 → ``ros2_control`` 永远卡在 wait_for_operational,
|
||||||
|
栈起来了、话题在、``/joint_states`` 是空的(全是 0)。现场看起来像"驱动没配好"。
|
||||||
|
2. 总线供电的小 Hub 在内核里卡死 → 挂它下面的四台设备一起消失
|
||||||
|
(腕部 D405 + 顶置 D435 + 左右视触觉)。现场看起来像"相机坏了"。
|
||||||
|
3. 上一轮的栈没退干净 → spawner 进程永远等一个不存在的 controller_manager,
|
||||||
|
这一轮再起栈,报错指向别处。
|
||||||
|
|
||||||
|
单独看每一个都不难,但它们**同时存在**时,日志里全是别人的症状,人会先怀疑软件。
|
||||||
|
所以这里把它们收成一条命令,按「根因在前、症状在后」输出,并给出**人工要做什么**。
|
||||||
|
|
||||||
|
设计约束(都是踩过的)
|
||||||
|
----------------------
|
||||||
|
* **绝不挂住**。任何子进程都有超时,sysfs 读取有超时(见 ``usb_health.SysfsReader``)。
|
||||||
|
一个卡死的 USB 设备曾让体检自己挂到超时被杀 —— 那等于什么都没查。
|
||||||
|
* **单项失败不连坐**。某个检查抛异常时把它转成一条结论,其余照跑。
|
||||||
|
* **人工动作去重后按优先级排**,因为现场是按顺序做的。
|
||||||
|
|
||||||
|
用法::
|
||||||
|
|
||||||
|
bash scripts/run_real_preflight.sh --arm 89
|
||||||
|
python3 scripts/lib/real_preflight.py --sn Rizon4-063989 # 已在真机环境里时
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SCRIPTS_LIB = ROOT / "scripts" / "lib"
|
||||||
|
sys.path.insert(0, str(SCRIPTS_LIB))
|
||||||
|
|
||||||
|
# 人工动作的措辞只有这一个来源:同一个文件也被 rdk_status.py import,
|
||||||
|
# 两处字面一致才能被 Report.manual_actions 去重成一条。
|
||||||
|
from manual_actions import NET_UNREACHABLE_ACTION # noqa: E402
|
||||||
|
|
||||||
|
LEVEL_OK = "ok"
|
||||||
|
LEVEL_WARN = "warn"
|
||||||
|
LEVEL_FAIL = "fail"
|
||||||
|
|
||||||
|
#: 级别排序:fail 最先报
|
||||||
|
_LEVEL_RANK = {LEVEL_FAIL: 0, LEVEL_WARN: 1, LEVEL_OK: 2}
|
||||||
|
|
||||||
|
DEFAULT_RDK_PYTHON = os.environ.get("RDK_PYTHON") or "/home/cc/rdk_py310/bin/python"
|
||||||
|
|
||||||
|
#: 上一轮没退干净的进程特征。留着它们会让这一轮的错误指向别处:
|
||||||
|
#: · ros2_control_node 占着 RDK(同一个 SN 是独占访问)
|
||||||
|
#: · spawner 永远在等一个不存在的 controller_manager,日志里全是 WARN
|
||||||
|
#: · 相机节点占着 USB(USB 是独占的,新进程只会报"打不开设备")
|
||||||
|
LEFTOVER_PATTERNS: Tuple[Tuple[str, str], ...] = (
|
||||||
|
("ros2_control_node", "ros2_control 还在跑(它占着 RDK,RDK 对同一台臂是独占的)"),
|
||||||
|
("stack.launch", "上一次的 arm_bringup 栈还在"),
|
||||||
|
("ros2 run arm_application realsense_color_pub", "相机节点还在跑(USB 独占)"),
|
||||||
|
("arm_application/tactile", "触觉服务还在跑(V4L 独占)"),
|
||||||
|
("dh_pgea", "夹爪节点还在跑(串口独占)"),
|
||||||
|
("controller_manager/spawner", "孤儿 spawner 还在等 controller_manager"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Check:
|
||||||
|
"""一项检查的结论。``level`` 只允许 ok/warn/fail。"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
level: str
|
||||||
|
lines: List[str] = field(default_factory=list)
|
||||||
|
#: 需要**人工**做的事(软件做不了的)。会被汇总、去重后按检查顺序输出。
|
||||||
|
actions: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Report:
|
||||||
|
checks: List[Check] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def level(self) -> str:
|
||||||
|
if any(c.level == LEVEL_FAIL for c in self.checks):
|
||||||
|
return LEVEL_FAIL
|
||||||
|
if any(c.level == LEVEL_WARN for c in self.checks):
|
||||||
|
return LEVEL_WARN
|
||||||
|
return LEVEL_OK
|
||||||
|
|
||||||
|
def manual_actions(self) -> List[str]:
|
||||||
|
"""汇总人工动作,去重且保持检查顺序(= 现场该做的顺序)。"""
|
||||||
|
out: List[str] = []
|
||||||
|
for c in self.checks:
|
||||||
|
for a in c.actions:
|
||||||
|
if a not in out:
|
||||||
|
out.append(a)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def as_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"level": self.level,
|
||||||
|
"checks": [c.as_dict() for c in self.checks],
|
||||||
|
"manual_actions": self.manual_actions(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 单项检查
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _json_tail(text: str) -> Dict[str, Any]:
|
||||||
|
"""从一段可能混着日志的文本里取**最后一个完整的 JSON 对象**。
|
||||||
|
|
||||||
|
为什么不 ``json.loads(text[text.find('{'):])``:RDK 的 C++ 日志里也有花括号,
|
||||||
|
JSON 后面还可能拖日志行,那样解析必然失败 —— 失败之后只能把原始文本打出来,
|
||||||
|
结论就丢了(实测就是这个现象)。``raw_decode`` 允许后面有剩余文本,
|
||||||
|
所以逐个候选起点试一遍、取最后成功的那个即可。
|
||||||
|
"""
|
||||||
|
dec = json.JSONDecoder()
|
||||||
|
best: Dict[str, Any] = {}
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
if ch != "{":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj, _end = dec.raw_decode(text[i:])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
best = obj
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd: Sequence[str], timeout_s: float) -> Tuple[int, str, str]:
|
||||||
|
"""跑一条命令并**一定有超时**。返回 (退出码, stdout, stderr)。
|
||||||
|
|
||||||
|
超时返回码 124(与 GNU timeout 一致)。体检里任何一处不能无限等 ——
|
||||||
|
卡死的 USB / 半连的机器人都会让普通的 subprocess.run 永远不返回。
|
||||||
|
|
||||||
|
stdout / stderr **分开返回**(不合并):RDK 自己的日志走 stderr,
|
||||||
|
而 ``rdk_status.py --json`` 的 JSON 在 stdout。合并之后 JSON 后面会拖一串
|
||||||
|
日志,``json.loads`` 必然失败 —— 实测踩过,表现为"体检说读不到 RDK 状态",
|
||||||
|
而实际那句话就在输出里。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
list(cmd), capture_output=True, text=True, timeout=timeout_s
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
out = exc.stdout or ""
|
||||||
|
err = exc.stderr or ""
|
||||||
|
if isinstance(out, bytes): # pragma: no cover - text=True 时不会走到
|
||||||
|
out = out.decode(errors="replace")
|
||||||
|
if isinstance(err, bytes): # pragma: no cover
|
||||||
|
err = err.decode(errors="replace")
|
||||||
|
return 124, out, (err + f"\n超时({timeout_s:.0f}s):{' '.join(cmd)}")
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
return 127, "", str(exc)
|
||||||
|
return p.returncode, p.stdout or "", p.stderr or ""
|
||||||
|
|
||||||
|
|
||||||
|
def check_leftovers(
|
||||||
|
ps_output: str, running_cmds: Sequence[str] = ()
|
||||||
|
) -> Check:
|
||||||
|
"""上一轮的残留进程。
|
||||||
|
|
||||||
|
``running_cmds`` 是**当前进程自己**的命令行(用于排除自己)——
|
||||||
|
以前用 ``pkill -f`` 清理时把自己的 shell 也匹配掉了,教训是:
|
||||||
|
匹配规则必须能区分"别人的进程"和"我这条命令"。这里改成显式排除。
|
||||||
|
"""
|
||||||
|
hits: List[str] = []
|
||||||
|
for line in ps_output.splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
if any(frag in line for frag in running_cmds):
|
||||||
|
continue
|
||||||
|
for pat, why in LEFTOVER_PATTERNS:
|
||||||
|
if pat in line and why not in hits:
|
||||||
|
hits.append(why)
|
||||||
|
if not hits:
|
||||||
|
return Check("leftovers", "遗留进程", LEVEL_OK, ["没有上一轮残留的进程"])
|
||||||
|
return Check(
|
||||||
|
"leftovers",
|
||||||
|
"遗留进程",
|
||||||
|
LEVEL_WARN,
|
||||||
|
["上一轮有进程没退干净,这一轮的现象会指向别处:"] + [f" · {h}" for h in hits],
|
||||||
|
actions=[
|
||||||
|
"先清干净再起栈:pkill -f '[r]os2_control_node';"
|
||||||
|
"pkill -f '[a]rm_bringup.*stack.launch';pkill -f '[s]pawner'"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_network(ping_code: int, ping_out: str, arp_lines: Sequence[str], ip: str) -> Check:
|
||||||
|
"""机器人是否在网里。
|
||||||
|
|
||||||
|
额外查一条**现场特有的坑**:两台臂的 ARP 指向同一个 MAC。2026-09-28 实测
|
||||||
|
到一次:``.100``(91)与 ``.101``(89)在 ARP 表里是**同一个 MAC**
|
||||||
|
``88:88:88:88:87:88``,而 ping ``.100`` 稳定、ping ``.101`` 丢 50~90%,
|
||||||
|
且**先 ping 一次 .100 之后 .101 丢包立刻加重**。这是同一台设备(或两台同
|
||||||
|
MAC 的设备)在同一 L2 段上争抢转发 —— 它不是"网络不通",而是"通得不稳",
|
||||||
|
足以让 RDK 的握手时好时坏。
|
||||||
|
|
||||||
|
这条只涉及**目标 IP 自己**的撞车才报;与目标无关的撞车只在 PING 不通时
|
||||||
|
作为解释提一句,避免正常的双机配置被误报。
|
||||||
|
"""
|
||||||
|
lines: List[str] = []
|
||||||
|
actions: List[str] = []
|
||||||
|
level = LEVEL_OK
|
||||||
|
|
||||||
|
last = [ln for ln in ping_out.splitlines() if ln.strip()]
|
||||||
|
if ping_code == 0:
|
||||||
|
lines.append(f"ping {ip}: 通")
|
||||||
|
else:
|
||||||
|
level = LEVEL_FAIL
|
||||||
|
lines.append(f"ping {ip}: 不通")
|
||||||
|
if last:
|
||||||
|
lines.append(f" {last[-1].strip()[:160]}")
|
||||||
|
actions.append(NET_UNREACHABLE_ACTION)
|
||||||
|
|
||||||
|
# 同一 MAC 对应多个 IP:按目标 IP 是否牵涉其中决定措辞与级别。
|
||||||
|
macs: Dict[str, List[str]] = {}
|
||||||
|
for line in arp_lines:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 5 and parts[0].startswith("192.168."):
|
||||||
|
macs.setdefault(parts[4].lower(), []).append(parts[0])
|
||||||
|
for mac, ips in macs.items():
|
||||||
|
if len(ips) < 2:
|
||||||
|
continue
|
||||||
|
involves_target = ip in ips
|
||||||
|
lines.append(
|
||||||
|
f" ARP:{' / '.join(sorted(ips))} 指向同一个 MAC {mac}"
|
||||||
|
)
|
||||||
|
if involves_target:
|
||||||
|
# 目标币自己牵涉其中:这是"通得不稳"的根因候选,比 ping 失败更值钱。
|
||||||
|
if level == LEVEL_OK:
|
||||||
|
level = LEVEL_WARN
|
||||||
|
actions.append(
|
||||||
|
f"两个 IP({' / '.join(sorted(ips))})在 ARP 里是同一个 MAC —— 要么是"
|
||||||
|
"同一台设备配了两个地址,要么是两台臂的 MAC 出厂就相同(同一 L2 段"
|
||||||
|
"会互相抢转发,表现为 ping 通但丢包、RDK 时连时断)。"
|
||||||
|
"先把另一台臂的网线拔掉/换到独立网段,只留当前这台,再重跑体检。"
|
||||||
|
)
|
||||||
|
if not involves_target and level == LEVEL_FAIL:
|
||||||
|
lines.append(" (与目标 IP 无关,仅供参考)")
|
||||||
|
break
|
||||||
|
|
||||||
|
return Check("network", "机器人在网", level, lines, actions=actions)
|
||||||
|
|
||||||
|
|
||||||
|
def check_arm_rdk(payload: Dict[str, Any], returncode: int) -> Check:
|
||||||
|
"""机械臂能不能被使能(这是"能不能动"的唯一权威判据)。
|
||||||
|
|
||||||
|
状态词与处置来自厂商 ``flexiv/rdk/data.hpp`` 的原话:
|
||||||
|
``IN_MANUAL_MODE`` = "In Manual mode, need to switch to Auto (Remote) mode."
|
||||||
|
"""
|
||||||
|
if returncode == 124:
|
||||||
|
return Check(
|
||||||
|
"arm",
|
||||||
|
"机械臂 RDK",
|
||||||
|
LEVEL_FAIL,
|
||||||
|
["读 RDK 状态超时 —— 通常是别的进程占着 RDK(见上一条遗留进程)"],
|
||||||
|
actions=["先清掉占用 RDK 的进程(ros2_control / 上一轮的栈),再重试"],
|
||||||
|
)
|
||||||
|
level = str(payload.get("level") or LEVEL_FAIL)
|
||||||
|
lines = [str(payload.get("summary") or payload.get("error") or "")]
|
||||||
|
if payload.get("connected"):
|
||||||
|
lines.append(
|
||||||
|
f" mode={payload.get('mode', '?')} "
|
||||||
|
f"operational={payload.get('operational')} "
|
||||||
|
f"op_status={payload.get('op_status', '?')} "
|
||||||
|
f"fault={payload.get('fault')} estop_released={payload.get('estop_released')}"
|
||||||
|
)
|
||||||
|
return Check(
|
||||||
|
"arm",
|
||||||
|
"机械臂 RDK",
|
||||||
|
level if level in _LEVEL_RANK else LEVEL_FAIL,
|
||||||
|
lines,
|
||||||
|
actions=[str(a) for a in (payload.get("actions") or [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_usb(reader: Any = None) -> Check:
|
||||||
|
"""USB 链路:RealSense 协商速率,以及 Hub 是否在内核里卡死。"""
|
||||||
|
import usb_health
|
||||||
|
|
||||||
|
rd = reader or usb_health.SysfsReader()
|
||||||
|
devices = usb_health.read_devices(reader=rd)
|
||||||
|
findings = usb_health.findings_for(devices, rd.blocked)
|
||||||
|
level = LEVEL_FAIL if usb_health.has_errors(findings) else (
|
||||||
|
LEVEL_WARN if any(f.level == LEVEL_WARN for f in findings) else LEVEL_OK
|
||||||
|
)
|
||||||
|
actions = []
|
||||||
|
if rd.blocked:
|
||||||
|
actions.append(
|
||||||
|
"某个 USB Hub 在内核里卡死(见上面 FAIL):给这个 Hub 断电重启,"
|
||||||
|
"或把设备改插到主板后面板直连的口;软件复位需要 root"
|
||||||
|
)
|
||||||
|
if any(f.level == "error" and "都没枚举到" in f.text for f in findings):
|
||||||
|
actions.append("一台 RealSense 都没枚举到:拔插一次,并确认没有被别的进程占着")
|
||||||
|
return Check(
|
||||||
|
"usb",
|
||||||
|
"USB 链路(相机)",
|
||||||
|
level,
|
||||||
|
[f"[{f.level}] {f.text}" for f in findings],
|
||||||
|
actions=actions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_v4l(pairs: Sequence[Tuple[str, str]], v4l_root: Optional[Path] = None) -> Check:
|
||||||
|
"""视触觉的 V4L2 设备在不在,以及是"没插"还是"卡死"。"""
|
||||||
|
import v4l_health
|
||||||
|
|
||||||
|
if not pairs:
|
||||||
|
return Check("v4l", "触觉相机(V4L2)", LEVEL_OK, ["本臂没有配触觉,跳过"])
|
||||||
|
checks = (
|
||||||
|
v4l_health.check_devices(pairs)
|
||||||
|
if v4l_root is None
|
||||||
|
else v4l_health.check_devices(pairs, v4l_root=v4l_root)
|
||||||
|
)
|
||||||
|
level = LEVEL_FAIL if any(c.level == "error" for c in checks) else LEVEL_OK
|
||||||
|
actions = []
|
||||||
|
if any(c.state == "wedged" for c in checks):
|
||||||
|
actions.append(
|
||||||
|
"触觉相机在内核里卡死(sysfs 还在、/dev/video* 没了):`sudo -n "
|
||||||
|
"/usr/local/bin/orisys-usb-reset <usb-path>` 就是为这种情况准备的"
|
||||||
|
"(免密白名单,无人值守可直接跑);它也不行才拔插该 USB 口"
|
||||||
|
)
|
||||||
|
elif level == LEVEL_FAIL:
|
||||||
|
actions.append("触觉相机没枚举到:确认插好(by-path 是按口认的,换口就会失配)")
|
||||||
|
return Check(
|
||||||
|
"v4l", "触觉相机(V4L2)", level, [c.text() for c in checks], actions=actions
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_cameras(
|
||||||
|
site_path: Path, sn: str, devices: Optional[Sequence[Any]] = None
|
||||||
|
) -> Check:
|
||||||
|
"""本臂配置的**腕部相机**(eye-in-hand 那只)必须在 USB 上。
|
||||||
|
|
||||||
|
为什么单独一条判据
|
||||||
|
------------------
|
||||||
|
``check_usb`` 只看 RealSense 的**协商速率**,对"这只相机**根本不在了**"
|
||||||
|
没有话可说 —— 它遍历的是**发现到的**设备。2026-09-28 现场实测到这只:
|
||||||
|
|
||||||
|
lsusb 里只剩顶置 D435,d405(89 的腕部相机、装配全程唯一的位置来源)
|
||||||
|
整个消失,而体检报告**全绿**。
|
||||||
|
|
||||||
|
也就是说,把相机碰掉了、或者它所在的 Hub 掉电了,报告照样说"可以开工"。
|
||||||
|
缺 D405 之后,踩的是完全另一类错误(点云为空 / 找不到工件),排查方向
|
||||||
|
会被带偏很远。所以这里按 site.yaml 的**配置意图**去核在不在:
|
||||||
|
``robots[<sn>].camera.name`` → ``cameras.<name>.model``,比的是型号。
|
||||||
|
|
||||||
|
为什么比型号而不是序列号:实测这台 D435 的 sysfs ``serial`` 是**空串**
|
||||||
|
(UVC 不一定把序列号透出来),拿序列号判会永远判"不在"。型号
|
||||||
|
(``d405`` / ``d435``)来自 USB product 串,稳定得多。序列号只用来
|
||||||
|
在**型号对得上**时做一次"是不是本臂那只"的提醒(WARN,不阻断)。
|
||||||
|
"""
|
||||||
|
import export_site_env as ex
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = ex.load_site(site_path)
|
||||||
|
_merged, robot = ex.apply_active_arm(data, sn)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return Check(
|
||||||
|
"cameras",
|
||||||
|
"腕部相机(USB)",
|
||||||
|
LEVEL_WARN,
|
||||||
|
[f"读不了 {site_path} 或找不到 SN={sn}:{type(exc).__name__}: {exc}"],
|
||||||
|
)
|
||||||
|
|
||||||
|
cam_name = str(((robot.get("camera") or {}) or {}).get("name") or "")
|
||||||
|
if not cam_name:
|
||||||
|
return Check(
|
||||||
|
"cameras", "腕部相机(USB)", LEVEL_OK, ["本臂没配腕部相机(camera.name),跳过"]
|
||||||
|
)
|
||||||
|
cam = (data.get("cameras") or {}).get(cam_name) or {}
|
||||||
|
if "sim" in cam_name or "sim" in str(cam.get("role") or ""):
|
||||||
|
return Check(
|
||||||
|
"cameras",
|
||||||
|
"腕部相机(USB)",
|
||||||
|
LEVEL_OK,
|
||||||
|
[f"本臂腕部相机 {cam_name} 是仿真源(真机不读),跳过"],
|
||||||
|
)
|
||||||
|
|
||||||
|
want_model = str(cam.get("model") or "").lower()
|
||||||
|
want_serial = str(cam.get("serial") or "")
|
||||||
|
if not want_model:
|
||||||
|
return Check(
|
||||||
|
"cameras",
|
||||||
|
"腕部相机(USB)",
|
||||||
|
LEVEL_WARN,
|
||||||
|
[f"cameras.{cam_name} 没写 model,无法核对是否插着"],
|
||||||
|
actions=[f"在 site.yaml 的 cameras.{cam_name} 里补上 model(如 d405)"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if devices is None:
|
||||||
|
import usb_health
|
||||||
|
|
||||||
|
devices = usb_health.read_devices(reader=usb_health.SysfsReader())
|
||||||
|
|
||||||
|
found = [d for d in devices if getattr(d, "model", "") == want_model]
|
||||||
|
realsense = [d for d in devices if getattr(d, "is_realsense", False)]
|
||||||
|
lines = [
|
||||||
|
f"本臂腕部相机:{cam_name}(期望 {want_model}"
|
||||||
|
+ (f",SN {want_serial}" if want_serial else "")
|
||||||
|
+ ")",
|
||||||
|
"USB 上现在有的 RealSense:"
|
||||||
|
+ (
|
||||||
|
"、".join(f"{d.model or d.product}({d.path})" for d in realsense)
|
||||||
|
or "一台都没有"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if not found:
|
||||||
|
return Check(
|
||||||
|
"cameras",
|
||||||
|
"腕部相机(USB)",
|
||||||
|
LEVEL_FAIL,
|
||||||
|
lines,
|
||||||
|
actions=[
|
||||||
|
f"本臂腕部相机 {want_model}({cam_name})没在 USB 上 —— 装配全程的位置"
|
||||||
|
f"都来自它,没有它后面每一步都会以别的样子报错。查:相机线/腕部延长线"
|
||||||
|
f"插紧(它常挂在腕部/第三轴的活动线上,反复弯折最先断),"
|
||||||
|
f"USB Hub 是否还在(lsusb),换一个口再 lsusb 确认。"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
level = LEVEL_OK
|
||||||
|
# 型号对上了,再看序列号对不对:两台臂各有一只同型号,插反了要能看出来。
|
||||||
|
serials = [d.serial for d in found if d.serial]
|
||||||
|
if want_serial:
|
||||||
|
if serials and want_serial not in serials:
|
||||||
|
level = LEVEL_WARN
|
||||||
|
lines.append(
|
||||||
|
f" [warn] 型号对得上,但序列号是 {serials},配置里写的是 {want_serial}"
|
||||||
|
" —— 可能把另一台臂的腕部相机插到了本臂上"
|
||||||
|
)
|
||||||
|
elif not serials:
|
||||||
|
lines.append(" (这只相机 sysfs 里没透出序列号,无法核对是不是本臂那只)")
|
||||||
|
return Check("cameras", "腕部相机(USB)", level, lines)
|
||||||
|
|
||||||
|
|
||||||
|
def check_geometry(site_path: Path, sn: str) -> Check:
|
||||||
|
"""手眼矩阵自洽 + 悬停高度会不会把工具捅进工件。"""
|
||||||
|
import real_geometry_check
|
||||||
|
|
||||||
|
try:
|
||||||
|
rep = real_geometry_check.geometry_report(site_path, sn)
|
||||||
|
except Exception as exc: # noqa: BLE001 —— 单项失败不能连坐
|
||||||
|
return Check(
|
||||||
|
"geometry",
|
||||||
|
"几何(手眼 / 悬停)",
|
||||||
|
LEVEL_WARN,
|
||||||
|
[f"跑不了几何检查:{type(exc).__name__}: {exc}"],
|
||||||
|
actions=["确认工作区已构建(install/ 里有 arm_application),再重跑体检"],
|
||||||
|
)
|
||||||
|
lines: List[str] = []
|
||||||
|
problems: List[str] = []
|
||||||
|
he = rep["handeye"]
|
||||||
|
lines.append(f"手眼:{he.get('file')}")
|
||||||
|
if he.get("translation_m"):
|
||||||
|
t = he["translation_m"]
|
||||||
|
lines.append(f" 平移 [{t[0]:+.4f}, {t[1]:+.4f}, {t[2]:+.4f}] m")
|
||||||
|
for p in he.get("problems") or []:
|
||||||
|
problems.append(p)
|
||||||
|
lines.append(f" [fail] {p}")
|
||||||
|
if not (he.get("problems") or []):
|
||||||
|
lines.append(" [ok ] 矩阵自洽(正交、det=+1、量级合理)")
|
||||||
|
|
||||||
|
hv = rep["hover"]
|
||||||
|
lines.append(
|
||||||
|
f"悬停:法兰 {hv['hole_hover_z_m']:.4f} − 工具长 "
|
||||||
|
f"{hv['tool_tip_below_flange_m'] * 1000:.1f}mm,工件面 "
|
||||||
|
f"{hv['workpiece_surface_z_m']:.4f}"
|
||||||
|
+ ("(未标定)" if hv.get("uncalibrated_surface") else "")
|
||||||
|
)
|
||||||
|
for p in hv.get("problems") or []:
|
||||||
|
problems.append(p)
|
||||||
|
lines.append(f" [fail] {p}")
|
||||||
|
actions = []
|
||||||
|
if hv.get("uncalibrated_surface"):
|
||||||
|
actions.append(
|
||||||
|
"实测工件上表面 z 并写回 site.yaml 的 robots[].gauge_assembly."
|
||||||
|
"workpiece_surface_z_m(跑 scripts/run_stage2_locate.sh 会把它打出来)"
|
||||||
|
)
|
||||||
|
return Check(
|
||||||
|
"geometry",
|
||||||
|
"几何(手眼 / 悬停)",
|
||||||
|
LEVEL_FAIL if problems else LEVEL_OK,
|
||||||
|
lines,
|
||||||
|
actions=actions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_environment(site_path: Path, sn: str) -> Check:
|
||||||
|
"""把这一轮到底在读哪份配置、认哪台设备打出来(换臂/换口最容易错)。"""
|
||||||
|
import export_site_env as ex
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = ex.load_site(site_path)
|
||||||
|
_merged, robot = ex.apply_active_arm(data, sn)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return Check(
|
||||||
|
"env",
|
||||||
|
"现场配置",
|
||||||
|
LEVEL_FAIL,
|
||||||
|
[f"读不了 {site_path} 或找不到 SN={sn}:{exc}"],
|
||||||
|
actions=[f"确认 site.yaml 与 --arm/--sn 一致(当前 {sn})"],
|
||||||
|
)
|
||||||
|
lines = [
|
||||||
|
f"site={site_path}",
|
||||||
|
f"SN={ex.resolve_robot_sn(data, sn)[0]} "
|
||||||
|
f"arm={robot.get('arm')} {robot.get('label') or ''}".rstrip(),
|
||||||
|
f"IP={robot.get('ip')} iface={robot.get('iface') or (data.get('net') or {}).get('iface')}",
|
||||||
|
]
|
||||||
|
cams = data.get("cameras") or {}
|
||||||
|
for key, cam in cams.items():
|
||||||
|
if not isinstance(cam, dict):
|
||||||
|
continue
|
||||||
|
res = f"{cam.get('width')}x{cam.get('height')}"
|
||||||
|
if cam.get("fps"):
|
||||||
|
res += f"@{cam['fps']}"
|
||||||
|
role = str(cam.get("role") or "")
|
||||||
|
tag = "(仿真用,真机不读)" if "sim" in role or str(key).startswith("sim_") else ""
|
||||||
|
lines.append(f"cameras.{key}: serial={cam.get('serial')} {res} {role}{tag}".rstrip())
|
||||||
|
grip = robot.get("gripper") or {}
|
||||||
|
if grip:
|
||||||
|
lines.append(
|
||||||
|
f"gripper: {grip.get('model')} port={grip.get('port')} "
|
||||||
|
f"auto_init={grip.get('auto_init')}"
|
||||||
|
)
|
||||||
|
if grip.get("auto_init"):
|
||||||
|
lines.append(
|
||||||
|
" 注意 auto_init=true:上电会先把手指张到满行程,"
|
||||||
|
"爪里已经夹着工件时会把工件放掉"
|
||||||
|
)
|
||||||
|
return Check("env", "现场配置", LEVEL_OK, lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 编排
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _ps_output() -> str:
|
||||||
|
exe = shutil.which("ps") or "/bin/ps"
|
||||||
|
code, out, _err = _run([exe, "-eo", "pid,cmd"], 5.0)
|
||||||
|
return out if code == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def run_all(
|
||||||
|
site_path: Path,
|
||||||
|
sn: str,
|
||||||
|
rdk_python: str = DEFAULT_RDK_PYTHON,
|
||||||
|
iface: str = "",
|
||||||
|
) -> Report:
|
||||||
|
"""跑完整套体检。顺序 = 输出的顺序 = 现场该处理的顺序。"""
|
||||||
|
rep = Report()
|
||||||
|
|
||||||
|
# 0) 现场配置:先把"在读哪份配置"钉住,后面所有结论都依赖它。
|
||||||
|
env_check = check_environment(site_path, sn)
|
||||||
|
rep.checks.append(env_check)
|
||||||
|
|
||||||
|
# 1) 遗留进程:它会让后面每一项的现象都指向别处,所以先查。
|
||||||
|
rep.checks.append(
|
||||||
|
check_leftovers(_ps_output(), running_cmds=(sys.argv[0] if sys.argv else "",))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2) 网络:RDK 连不上的前提条件。
|
||||||
|
ip = ""
|
||||||
|
try:
|
||||||
|
import export_site_env as ex
|
||||||
|
|
||||||
|
data = ex.load_site(site_path)
|
||||||
|
_merged, robot = ex.apply_active_arm(data, sn)
|
||||||
|
ip = str(robot.get("ip") or "")
|
||||||
|
iface = iface or str(robot.get("iface") or (data.get("net") or {}).get("iface") or "")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
if ip:
|
||||||
|
code, out, _err = _run(["ping", "-c", "2", "-W", "1", ip], 8.0)
|
||||||
|
_arp_code, arp, _arp_err = _run(["ip", "neigh", "show"], 5.0)
|
||||||
|
rep.checks.append(check_network(code, out, arp.splitlines() if arp else [], ip))
|
||||||
|
|
||||||
|
# 3) 机械臂:能不能使能。
|
||||||
|
status_py = SCRIPTS_LIB / "rdk_status.py"
|
||||||
|
if status_py.is_file():
|
||||||
|
cmd = [rdk_python, str(status_py), "--sn", sn, "--json"]
|
||||||
|
if iface:
|
||||||
|
cmd += ["--iface", iface]
|
||||||
|
# 磁盘上没有的 SN 会走完整搜索超时;给足但绝不无限等。
|
||||||
|
code, out, err = _run(cmd, 90.0)
|
||||||
|
payload = _json_tail(out) or _json_tail(err) or {}
|
||||||
|
if not payload:
|
||||||
|
payload = {
|
||||||
|
"level": LEVEL_FAIL,
|
||||||
|
"error": (out + err).strip()[-400:] or "读不到 RDK 状态",
|
||||||
|
}
|
||||||
|
rep.checks.append(check_arm_rdk(payload, code))
|
||||||
|
|
||||||
|
# 4) USB / 触觉 / 几何:都不需要使能机械臂,随时可查。
|
||||||
|
# 腕部相机排在 USB 之前:USB 那条只说"速率对不对",这条说"在不在",
|
||||||
|
# 而"不在"是更硬的阻断,必须先报。
|
||||||
|
for fn in (
|
||||||
|
lambda: check_cameras(site_path, sn),
|
||||||
|
lambda: check_usb(),
|
||||||
|
lambda: check_v4l(_tactile_pairs(site_path, sn)),
|
||||||
|
lambda: check_geometry(site_path, sn),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
rep.checks.append(fn())
|
||||||
|
except Exception as exc: # noqa: BLE001 —— 单项失败不连坐
|
||||||
|
rep.checks.append(
|
||||||
|
Check(
|
||||||
|
"unknown",
|
||||||
|
"检查项异常",
|
||||||
|
LEVEL_WARN,
|
||||||
|
[f"{type(exc).__name__}: {exc}"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rep.checks.sort(key=lambda c: _LEVEL_RANK.get(c.level, 3))
|
||||||
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
def _tactile_pairs(site_path: Path, sn: str) -> List[Tuple[str, str]]:
|
||||||
|
"""从 site.yaml 取本臂触觉的 by-path(按口认设备,换口就会失配)。
|
||||||
|
|
||||||
|
注意 ``robots[].tactile`` 的写法有两种:布尔开关(本臂装没装)和
|
||||||
|
参数覆盖字典。以前只按字典处理,遇到 ``tactile: true`` 就
|
||||||
|
``dict.update(True)`` 抛 TypeError —— 现象是"整个触觉检查项没跑",
|
||||||
|
而不是报错,很容易被当成"这台没触觉"。
|
||||||
|
"""
|
||||||
|
import export_site_env as ex
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = ex.load_site(site_path)
|
||||||
|
_merged, robot = ex.apply_active_arm(data, sn)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return []
|
||||||
|
tac = dict(data.get("tactile") or {})
|
||||||
|
override = robot.get("tactile")
|
||||||
|
if isinstance(override, dict):
|
||||||
|
tac.update(override)
|
||||||
|
elif override is not None and not override:
|
||||||
|
return [] # 显式关掉
|
||||||
|
if not tac:
|
||||||
|
return []
|
||||||
|
pairs: List[Tuple[str, str]] = []
|
||||||
|
for side, key in (("左指", "left_video"), ("右指", "right_video")):
|
||||||
|
path = str(tac.get(key) or "")
|
||||||
|
if path:
|
||||||
|
pairs.append((side, path))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def render(rep: Report) -> str:
|
||||||
|
marks = {LEVEL_OK: "OK ", LEVEL_WARN: "WARN", LEVEL_FAIL: "FAIL"}
|
||||||
|
out: List[str] = []
|
||||||
|
for c in rep.checks:
|
||||||
|
out.append(f"[{marks.get(c.level, '? ')}] {c.title}")
|
||||||
|
for line in c.lines:
|
||||||
|
if line:
|
||||||
|
out.append(f" {line}")
|
||||||
|
out.append("")
|
||||||
|
out.append("=" * 68)
|
||||||
|
if rep.level == LEVEL_OK:
|
||||||
|
out.append("结论:GO —— 所有检查通过,可以起栈跑流程。")
|
||||||
|
else:
|
||||||
|
out.append(
|
||||||
|
f"结论:NO-GO({'有硬阻断' if rep.level == LEVEL_FAIL else '有告警'})"
|
||||||
|
)
|
||||||
|
acts = rep.manual_actions()
|
||||||
|
if acts:
|
||||||
|
out.append("")
|
||||||
|
out.append("要人工做的事(按顺序,软件做不了的部分):")
|
||||||
|
for i, a in enumerate(acts, 1):
|
||||||
|
out.append(f" {i}. {a}")
|
||||||
|
else:
|
||||||
|
out.append(" (没有需要人工处理的项,见上面 WARN 的具体内容)")
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="真机开工前的一次性体检")
|
||||||
|
ap.add_argument("--sn", default="", help="机械臂 SN 或别名(89/91)")
|
||||||
|
ap.add_argument("--site", default=str(ROOT / "config" / "site.yaml"))
|
||||||
|
ap.add_argument("--iface", default="")
|
||||||
|
ap.add_argument("--rdk-python", default=DEFAULT_RDK_PYTHON)
|
||||||
|
ap.add_argument("--json", action="store_true")
|
||||||
|
ap.add_argument("--out", default="", help="把结论写到这个文件(JSON)")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
if not args.sn:
|
||||||
|
ap.error("--sn 必填(89 / 91 / Rizon4-XXXXXX)")
|
||||||
|
|
||||||
|
rep = run_all(Path(args.site), args.sn, args.rdk_python, args.iface)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(rep.as_dict(), ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
print(render(rep))
|
||||||
|
if args.out:
|
||||||
|
Path(args.out).write_text(
|
||||||
|
json.dumps(rep.as_dict(), ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return 0 if rep.level == LEVEL_OK else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 真机开工前的**一次性体检**(一条命令说清"能不能开工、不能的话人要去做什么")。
|
||||||
|
#
|
||||||
|
# bash ~/ros2/scripts/run_real_preflight.sh --arm 89
|
||||||
|
# bash ~/ros2/scripts/run_real_preflight.sh --arm 89 --json # 给脚本消费
|
||||||
|
# bash ~/ros2/scripts/run_real_preflight.sh --arm 89 --watch-usb 10
|
||||||
|
#
|
||||||
|
# 查什么(顺序就是现场该处理的顺序:根因在前,症状在后):
|
||||||
|
# 1. 现场配置:这一轮在读哪份 site.yaml、认哪台臂/哪台相机/哪个串口
|
||||||
|
# 2. 遗留进程:上一轮没退干净的栈会让这一轮的现象指向别处
|
||||||
|
# 3. 网络:机器人是否在网(含"两台臂 ARP 撞同一个 MAC"这个坑)
|
||||||
|
# 4. 机械臂 RDK:能不能使能(Manual 模式是**硬阻断**,只能人工在示教器上切)
|
||||||
|
# 5. USB:RealSense 协商速率 + Hub 是否在内核里卡死
|
||||||
|
# 6. 触觉 V4L2:设备在不在,是"没插"还是"卡死"
|
||||||
|
# 7. 几何:手眼矩阵是否自洽 + 悬停高度会不会把工具捅进工件
|
||||||
|
#
|
||||||
|
# 退出码:0 = 全绿(GO);1 = 有需要处理的项(NO-GO,看输出里"要人工做的事")。
|
||||||
|
#
|
||||||
|
# 为什么要有这个而不是让人挨个跑:这些故障**同时出现时现象会互相串**
|
||||||
|
#(Hub 卡死会让相机消失,相机消失又会让 S00 自检报"缺相机",
|
||||||
|
# 而真正拦住机械臂的其实是示教器模式)。分开查会让人先怀疑软件。
|
||||||
|
#
|
||||||
|
# 注意:这里**不加** `set -u` —— conda/RoboStack 的钩子会引用未绑定的变量。
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
WATCH_USB=0
|
||||||
|
JSON=false
|
||||||
|
|
||||||
|
# --sn/--arm 必须先解析:load_site_config.sh 靠 ROBOT_SN 决定选哪台臂
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${ROOT}/scripts/lib/parse_arm_args.sh"
|
||||||
|
parse_arm_args "$@"
|
||||||
|
set -- "${PARSE_ARM_REST[@]}"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--watch-usb) WATCH_USB="${2:?--watch-usb 需要秒数}"; shift 2 ;;
|
||||||
|
--json) JSON=true; shift ;;
|
||||||
|
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||||
|
*) echo "Unknown: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${ROOT}/scripts/env.sh" >/dev/null 2>&1 || true
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${ROOT}/scripts/lib/load_site_config.sh"
|
||||||
|
|
||||||
|
# site.yaml 不存在时不要直接失败:网络与 RDK 自检在配置前就该能用,
|
||||||
|
# 而"配置没写"本身也是体检要报出来的一项。
|
||||||
|
if [[ "${SITE_LOAD_STATUS}" == "2" ]]; then
|
||||||
|
echo "ERROR: site.yaml 有问题,先按上面的提示修好再体检。" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
SN="${SITE_ROBOT_SN:-${ROBOT_SN:-}}"
|
||||||
|
if [[ -z "${SN}" ]]; then
|
||||||
|
echo "ERROR: 没选到机械臂。给命令加 --arm <臂号>,或在 site.yaml 里填 robot.sn。" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== 真机开工体检 arm=${SITE_ROBOT_ARM:-?} ${SITE_ROBOT_LABEL:-} (${SN}) ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
ARGS=(--sn "${SN}" --site "${SITE_CONFIG:-${ROOT}/config/site.yaml}")
|
||||||
|
[[ -n "${SITE_NET_IFACE:-}" ]] && ARGS+=(--iface "${SITE_NET_IFACE}")
|
||||||
|
[[ "${JSON}" == true ]] && ARGS+=(--json)
|
||||||
|
|
||||||
|
RC=0
|
||||||
|
if [[ "${WATCH_USB}" != "0" ]]; then
|
||||||
|
# 先单独盯一会儿 USB:Hub 半死时会"设备在 / 不在"反复跳,
|
||||||
|
# 单次采样只会看到一个瞬间,看不出它在抖。
|
||||||
|
echo "--- USB 链路盯 ${WATCH_USB}s(看掉线/重连)---"
|
||||||
|
python3 "${ROOT}/scripts/lib/usb_health.py" --watch "${WATCH_USB}" || RC=$?
|
||||||
|
echo
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 "${ROOT}/scripts/lib/real_preflight.py" "${ARGS[@]}" || RC=$?
|
||||||
|
exit "${RC}"
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
"""开工体检的编排判据:**根因必须排在症状前面**,且任何一项都不能把体检拖垮。
|
||||||
|
|
||||||
|
这个文件钉三件事,都是这次无人值守实测踩出来的:
|
||||||
|
|
||||||
|
1. **顺序**。现场同时存在多个故障时,日志里全是"别人的症状":
|
||||||
|
示教器还在 Manual(拦住机械臂)、总线 Hub 在内核里卡死(相机全没了)、
|
||||||
|
上一轮栈没退干净(spawner 永远在等)。人看到的是"相机不见了",
|
||||||
|
于是去查相机 —— 而真正的硬阻断是示教器模式。所以 fail 必须排在最前,
|
||||||
|
并且"要人工做的事"要按这个顺序给。
|
||||||
|
2. **不挂**。一个卡死的 USB 设备曾让体检自己挂到超时被杀(sysfs 读取永久阻塞)。
|
||||||
|
所以任何外部调用都必须有超时,且超时要变成一条结论。
|
||||||
|
3. **不连坐**。某一项自己抛异常时,其余检查必须照跑 —— 否则一个坏掉的
|
||||||
|
可选检查会让"机械臂能不能动"这个最关键的问题没有答案。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts" / "lib"))
|
||||||
|
|
||||||
|
import real_preflight as pf # noqa: E402
|
||||||
|
from real_preflight import Check, Report, check_leftovers, check_network # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 汇总与顺序
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_level_is_fail_if_anything_fails() -> None:
|
||||||
|
rep = Report([Check("a", "A", pf.LEVEL_OK), Check("b", "B", pf.LEVEL_FAIL)])
|
||||||
|
assert rep.level == pf.LEVEL_FAIL
|
||||||
|
rep2 = Report([Check("a", "A", pf.LEVEL_OK), Check("b", "B", pf.LEVEL_WARN)])
|
||||||
|
assert rep2.level == pf.LEVEL_WARN
|
||||||
|
assert Report([Check("a", "A", pf.LEVEL_OK)]).level == pf.LEVEL_OK
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_actions_are_deduped_and_ordered() -> None:
|
||||||
|
"""同一句建议被两项都提到时只出现一次(现场按清单做,重复会让人以为漏了)。"""
|
||||||
|
rep = Report(
|
||||||
|
[
|
||||||
|
Check("a", "A", pf.LEVEL_FAIL, actions=["插网线", "开关"]),
|
||||||
|
Check("b", "B", pf.LEVEL_FAIL, actions=["插网线"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert rep.manual_actions() == ["插网线", "开关"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_cause_is_reported_before_the_symptom() -> None:
|
||||||
|
"""一条 fail 的"根因"与一条 fail 的"症状"同时存在时,两者都要在,
|
||||||
|
但建议清单里根因的处置必须排在前面。"""
|
||||||
|
root = Check(
|
||||||
|
"arm",
|
||||||
|
"机械臂 RDK",
|
||||||
|
pf.LEVEL_FAIL,
|
||||||
|
["示教器在 Manual 模式"],
|
||||||
|
actions=["示教器切到 Auto (Remote)"],
|
||||||
|
)
|
||||||
|
symptom = Check(
|
||||||
|
"usb", "USB", pf.LEVEL_FAIL, ["一台相机都没枚举到"], actions=["拔插相机"]
|
||||||
|
)
|
||||||
|
rep = Report([symptom, root])
|
||||||
|
rep.checks.sort(key=lambda c: pf._LEVEL_RANK.get(c.level, 3)) # run_all 的做法
|
||||||
|
assert [c.key for c in rep.checks] == ["usb", "arm"] # 同级保持稳定顺序
|
||||||
|
# 顺序由 run_all 决定(根因检查项先加入),这里只钉住"根因那条在前"的编排意图:
|
||||||
|
assert pf.Report([root, symptom]).manual_actions()[0] == "示教器切到 Auto (Remote)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_all_puts_arm_before_symptom_checks() -> None:
|
||||||
|
"""真编排里的顺序:机械臂(硬阻断)在 USB/触觉/几何之前。"""
|
||||||
|
src = (Path(pf.__file__)).read_text(encoding="utf-8")
|
||||||
|
body = src[src.index("def run_all") : src.index("def _tactile_pairs")]
|
||||||
|
assert body.index("check_arm_rdk") < body.index("check_usb"), (
|
||||||
|
"机械臂(能不能动)必须排在 USB 之前 —— 它是唯一的人工硬阻断"
|
||||||
|
)
|
||||||
|
assert body.index("check_leftovers") < body.index("check_network"), (
|
||||||
|
"遗留进程要先查:它会让后面每一项的现象都指向别处"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_says_go_only_when_everything_is_ok() -> None:
|
||||||
|
go = pf.render(Report([Check("a", "A", pf.LEVEL_OK, ["一切正常"])]))
|
||||||
|
assert "GO" in go and "NO-GO" not in go
|
||||||
|
nogo = pf.render(
|
||||||
|
Report([Check("a", "A", pf.LEVEL_FAIL, ["坏了"], actions=["去修"])])
|
||||||
|
)
|
||||||
|
assert "NO-GO" in nogo and "要人工做的事" in nogo and "1. 去修" in nogo
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 单项判据
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_leftovers_reports_orphan_spawner_and_ros2_control() -> None:
|
||||||
|
ps = (
|
||||||
|
" 100 /x/lib/controller_manager/spawner joint_state_broadcaster\n"
|
||||||
|
" 200 /y/lib/controller_manager/ros2_control_node\n"
|
||||||
|
" 300 /usr/bin/htop\n"
|
||||||
|
)
|
||||||
|
c = check_leftovers(ps)
|
||||||
|
assert c.level == pf.LEVEL_WARN
|
||||||
|
assert any("ros2_control" in ln for ln in c.lines)
|
||||||
|
assert c.actions, "要说清怎么清,不能只说'有残留'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leftovers_does_not_match_its_own_command_line() -> None:
|
||||||
|
"""曾经的坑:pkill -f 把自己的 shell 也匹配掉了(命令里含同样的字符串)。"""
|
||||||
|
ps = " 42 /bin/bash -c pkill -f '[a]rm_bringup.*stack.launch'\n"
|
||||||
|
c = check_leftovers(ps, running_cmds=("/bin/bash -c pkill",))
|
||||||
|
assert c.level == pf.LEVEL_OK
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_ok_when_ping_works() -> None:
|
||||||
|
c = check_network(0, "3 received", [], "192.168.2.101")
|
||||||
|
assert c.level == pf.LEVEL_OK and not c.actions
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_flags_same_mac_for_two_robot_ips() -> None:
|
||||||
|
"""2026-09-28 实测:.100/.101 是同一个 MAC,ping .101 丢 50~90%。"""
|
||||||
|
arp = [
|
||||||
|
"192.168.2.100 dev enp4s0 lladdr 88:88:88:88:87:88 REACHABLE",
|
||||||
|
"192.168.2.101 dev enp4s0 lladdr 88:88:88:88:87:88 REACHABLE",
|
||||||
|
]
|
||||||
|
c = check_network(0, "ok", arp, "192.168.2.101")
|
||||||
|
assert c.level == pf.LEVEL_WARN, "ping 通但同 MAC 抢转发,必须提一句"
|
||||||
|
assert any("88:88:88:88:87:88" in ln for ln in c.lines)
|
||||||
|
assert c.actions and "拔掉" in c.actions[0]
|
||||||
|
# 与目标无关的同 MAC 不打扰(正常双机配置不该被误报)
|
||||||
|
c2 = check_network(0, "ok", arp, "192.168.2.55")
|
||||||
|
assert c2.level == pf.LEVEL_OK
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_ping_fail_is_fail_and_shows_only_the_summary_line() -> None:
|
||||||
|
out = "PING 192.168.2.101 (192.168.2.101) 56(84) bytes of data.\n\n--- statistics ---\n2 packets transmitted, 0 received, 100% packet loss\n"
|
||||||
|
c = check_network(1, out, [], "192.168.2.101")
|
||||||
|
assert c.level == pf.LEVEL_FAIL
|
||||||
|
assert not any("bytes of data" in ln for ln in c.lines), "不要把 ping 原文刷进来"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RDK 结论的翻译
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_manual_mode_is_a_hard_block_with_a_manual_action() -> None:
|
||||||
|
payload = {
|
||||||
|
"connected": True,
|
||||||
|
"level": "fail",
|
||||||
|
"summary": "示教器在 Manual 模式(operational_status=OperationalStatus.IN_MANUAL_MODE)",
|
||||||
|
"actions": ["示教器切到 Auto (Remote)"],
|
||||||
|
"mode": "IDLE",
|
||||||
|
"operational": False,
|
||||||
|
"op_status": "OperationalStatus.IN_MANUAL_MODE",
|
||||||
|
"fault": False,
|
||||||
|
"estop_released": True,
|
||||||
|
}
|
||||||
|
c = pf.check_arm_rdk(payload, 0)
|
||||||
|
assert c.level == pf.LEVEL_FAIL
|
||||||
|
assert c.actions == ["示教器切到 Auto (Remote)"]
|
||||||
|
assert any("IN_MANUAL_MODE" in ln for ln in c.lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_arm_timeout_points_at_the_running_stack() -> None:
|
||||||
|
c = pf.check_arm_rdk({}, 124)
|
||||||
|
assert c.level == pf.LEVEL_FAIL
|
||||||
|
assert any("超时" in ln for ln in c.lines)
|
||||||
|
assert c.actions, "超时也要给下一步(RDK 被占用是最常见原因)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_level_from_payload_is_treated_as_fail() -> None:
|
||||||
|
"""级别词表外的值不能当成功 —— 脚本按 ok/warn/fail 判定。"""
|
||||||
|
c = pf.check_arm_rdk({"level": "weird"}, 0)
|
||||||
|
assert c.level == pf.LEVEL_FAIL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# JSON 提取(RDK 的日志会混进 stdout/stderr)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_tail_survives_surrounding_logs() -> None:
|
||||||
|
text = (
|
||||||
|
'[info] Flexiv RDK v1.9.1 | {not json}\n'
|
||||||
|
'{"connected": true, "level": "ok"}\n'
|
||||||
|
"[info] >>>>> Flexiv RDK exited <<<<<\n"
|
||||||
|
)
|
||||||
|
assert pf._json_tail(text) == {"connected": True, "level": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_tail_picks_the_last_object() -> None:
|
||||||
|
text = '{"a": 1}\nnoise\n{"b": 2}'
|
||||||
|
assert pf._json_tail(text) == {"b": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_tail_returns_empty_when_there_is_nothing() -> None:
|
||||||
|
assert pf._json_tail("no json here") == {}
|
||||||
|
assert pf._json_tail("") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_tail_is_not_confused_by_braces_in_logs() -> None:
|
||||||
|
text = "warning: {unknown token} in config\n" '{"level": "fail", "error": "x"}'
|
||||||
|
assert pf._json_tail(text)["level"] == "fail"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 触觉配置的两种写法
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_tactile_pairs_accepts_bool_flag(tmp_path: Path) -> None:
|
||||||
|
"""robots[].tactile 是布尔开关时不能抛 TypeError(曾经整个触觉检查项没跑)。"""
|
||||||
|
yml = tmp_path / "site.yaml"
|
||||||
|
yml.write_text(
|
||||||
|
"robot:\n"
|
||||||
|
" sn: Rizon4-000089\n"
|
||||||
|
" robots:\n"
|
||||||
|
" - sn: Rizon4-000089\n"
|
||||||
|
" arm: left\n"
|
||||||
|
" ip: 192.168.2.101\n"
|
||||||
|
" tactile: true\n"
|
||||||
|
"tactile:\n"
|
||||||
|
" left_video: /dev/v4l/by-path/x-3.4.1-y\n"
|
||||||
|
" right_video: /dev/v4l/by-path/x-3.4.2-y\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
pairs = pf._tactile_pairs(yml, "Rizon4-000089")
|
||||||
|
assert [n for n, _ in pairs] == ["左指", "右指"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tactile_pairs_empty_when_disabled(tmp_path: Path) -> None:
|
||||||
|
"""tactile: false 的臂不该被拉起来查触觉(右臂就没装)。"""
|
||||||
|
yml = tmp_path / "site.yaml"
|
||||||
|
yml.write_text(
|
||||||
|
"robot:\n"
|
||||||
|
" sn: Rizon4-000089\n"
|
||||||
|
" robots:\n"
|
||||||
|
" - sn: Rizon4-000089\n"
|
||||||
|
" arm: left\n"
|
||||||
|
" ip: 192.168.2.101\n"
|
||||||
|
" tactile: false\n"
|
||||||
|
"tactile:\n"
|
||||||
|
" left_video: /dev/v4l/by-path/x-3.4.1-y\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert pf._tactile_pairs(yml, "Rizon4-000089") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 腕部相机(D405)在不在 —— "速率对不对"和"在不在"是两件事
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _site_with_wrist(tmp_path: Path, *, model: str, serial: str) -> Path:
|
||||||
|
yml = tmp_path / "site.yaml"
|
||||||
|
yml.write_text(
|
||||||
|
"robot:\n"
|
||||||
|
" sn: Rizon4-000089\n"
|
||||||
|
" robots:\n"
|
||||||
|
" - sn: Rizon4-000089\n"
|
||||||
|
" arm: left\n"
|
||||||
|
" ip: 192.168.2.101\n"
|
||||||
|
" camera:\n"
|
||||||
|
" name: d405_left\n"
|
||||||
|
"cameras:\n"
|
||||||
|
" d405_left:\n"
|
||||||
|
f" model: {model}\n"
|
||||||
|
f" serial: \"{serial}\"\n"
|
||||||
|
" role: left_wrist\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return yml
|
||||||
|
|
||||||
|
|
||||||
|
def _realsense(path: str, product: str, serial: str = "") -> object:
|
||||||
|
from usb_health import UsbDevice
|
||||||
|
|
||||||
|
return UsbDevice(
|
||||||
|
path=path, vid="8086", pid="0b07", product=product, speed_mbps=5000, serial=serial
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cameras_fails_when_the_wrist_camera_is_gone(tmp_path: Path) -> None:
|
||||||
|
"""只剩顶置 D435 时必须报 FAIL。
|
||||||
|
|
||||||
|
现场那次(2026-09-28)D405 整个从 lsusb 消失,而旧体检**全绿** ——
|
||||||
|
它只看协商速率,对"这只相机根本不在了"没话说。
|
||||||
|
"""
|
||||||
|
yml = _site_with_wrist(tmp_path, model="d405", serial="352122274408")
|
||||||
|
devices = [_realsense("1-3.2", "Intel(R) RealSense(TM) Depth Camera 435")]
|
||||||
|
c = pf.check_cameras(yml, "Rizon4-000089", devices=devices)
|
||||||
|
assert c.level == pf.LEVEL_FAIL
|
||||||
|
assert "d405" in " ".join(c.lines)
|
||||||
|
assert "1-3.2" in " ".join(c.lines), "要把现在有的列出来,人才知道差哪只"
|
||||||
|
assert c.actions, "光报错不够,要说去查什么(腕部活动线最先断)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cameras_ok_when_wrist_camera_present(tmp_path: Path) -> None:
|
||||||
|
yml = _site_with_wrist(tmp_path, model="d405", serial="352122274408")
|
||||||
|
devices = [_realsense("1-3.2", "Intel(R) RealSense(TM) Depth Camera 435")]
|
||||||
|
devices.append(
|
||||||
|
_realsense("1-3.5", "Intel(R) RealSense(TM) Depth Camera 405", "352122274408")
|
||||||
|
)
|
||||||
|
c = pf.check_cameras(yml, "Rizon4-000089", devices=devices)
|
||||||
|
assert c.level == pf.LEVEL_OK
|
||||||
|
|
||||||
|
|
||||||
|
def test_cameras_warns_when_another_arms_wrist_camera_is_plugged_in(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""两台臂各有一只同型号:插反了要在开工前看出来(不阻断,但要报)。"""
|
||||||
|
yml = _site_with_wrist(tmp_path, model="d405", serial="352122274408")
|
||||||
|
devices = [
|
||||||
|
_realsense("1-3.5", "Intel(R) RealSense(TM) Depth Camera 405", "260522274442")
|
||||||
|
]
|
||||||
|
c = pf.check_cameras(yml, "Rizon4-000089", devices=devices)
|
||||||
|
assert c.level == pf.LEVEL_WARN
|
||||||
|
assert "260522274442" in " ".join(c.lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cameras_does_not_use_serial_alone(tmp_path: Path) -> None:
|
||||||
|
"""sysfs 不透出序列号(实测 D435 就是空串)时不能判成"不在"。"""
|
||||||
|
yml = _site_with_wrist(tmp_path, model="d405", serial="352122274408")
|
||||||
|
devices = [_realsense("1-3.5", "Intel(R) RealSense(TM) Depth Camera 405", "")]
|
||||||
|
c = pf.check_cameras(yml, "Rizon4-000089", devices=devices)
|
||||||
|
assert c.level == pf.LEVEL_OK
|
||||||
|
|
||||||
|
|
||||||
|
def test_cameras_skips_sim_arm(tmp_path: Path) -> None:
|
||||||
|
"""仿真臂不该去 USB 上找相机。"""
|
||||||
|
yml = tmp_path / "site.yaml"
|
||||||
|
yml.write_text(
|
||||||
|
"robot:\n"
|
||||||
|
" sn: sim\n"
|
||||||
|
" robots:\n"
|
||||||
|
" - sn: sim\n"
|
||||||
|
" arm: sim\n"
|
||||||
|
" camera:\n"
|
||||||
|
" name: sim_d405\n"
|
||||||
|
"cameras:\n"
|
||||||
|
" sim_d405:\n"
|
||||||
|
" model: d405\n"
|
||||||
|
" role: sim_wrist\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
c = pf.check_cameras(yml, "sim", devices=[])
|
||||||
|
assert c.level == pf.LEVEL_OK
|
||||||
|
assert "仿真" in " ".join(c.lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 同一件事在报告里只能说一次(跨模块的措辞回归)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_action_appears_once_in_the_manual_list() -> None:
|
||||||
|
"""check_network 与 rdk_status 给的"去查网线"必须是**同一串字**。
|
||||||
|
|
||||||
|
去重是按整串文字做的。两处各写一句意思相同、字面不同的话,现场就会
|
||||||
|
读到两条(实测就是:第 1 条和第 3 条是同一件事),人还得判断
|
||||||
|
"这是两个问题还是一个"。措辞已抽到 manual_actions,这里钉住它。
|
||||||
|
"""
|
||||||
|
import rdk_status
|
||||||
|
|
||||||
|
_level, _summary, rdk_actions = rdk_status.classify_state(
|
||||||
|
connected=False, error="连不上"
|
||||||
|
)
|
||||||
|
net = check_network(1, "100% packet loss", [], "192.168.2.101")
|
||||||
|
rep = Report(
|
||||||
|
[
|
||||||
|
net,
|
||||||
|
Check("arm", "机械臂 RDK", pf.LEVEL_FAIL, ["连不上"], actions=list(rdk_actions)),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
acts = rep.manual_actions()
|
||||||
|
net_acts = [a for a in acts if "check_robot_network.sh" in a]
|
||||||
|
assert len(net_acts) == 1, f"网线那条重复了:{net_acts}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 超时
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_times_out_instead_of_hanging() -> None:
|
||||||
|
"""体检里有子进程时必须有超时:卡死的设备会让普通调用永不返回。"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
code, out, err = pf._run([sys.executable, "-c", "import time; time.sleep(30)"], 0.5)
|
||||||
|
assert code == 124
|
||||||
|
assert time.monotonic() - t0 < 10.0
|
||||||
|
assert "超时" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_keeps_stdout_and_stderr_separate() -> None:
|
||||||
|
"""RDK 的日志走 stderr、JSON 走 stdout;合并会让 JSON 解析必然失败。"""
|
||||||
|
code, out, err = pf._run(
|
||||||
|
[sys.executable, "-c", "import sys; print('{\"a\":1}'); print('log', file=sys.stderr)"],
|
||||||
|
10.0,
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
assert out.strip() == '{"a":1}'
|
||||||
|
assert "log" in err
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 编排能真的跑完(回归:_run 改成三返回值后,有一处还在按两返回值解包,
|
||||||
|
# 于是体检**第一步就崩**,什么结论都没给出来)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_all_completes_without_crashing(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
"""整套编排必须能跑到底并返回 Report —— 崩在任何一行都等于"没体检"。"""
|
||||||
|
calls: list = []
|
||||||
|
|
||||||
|
def fake_run(cmd, timeout_s): # noqa: ANN001
|
||||||
|
calls.append(list(cmd))
|
||||||
|
exe = Path(str(cmd[0])).name
|
||||||
|
if exe == "ps":
|
||||||
|
return 0, " 1 /usr/bin/ros2_control_node --ros-args\n", ""
|
||||||
|
if exe == "ping":
|
||||||
|
return 0, "2 packets transmitted, 2 received, 0% packet loss\n", ""
|
||||||
|
if exe == "ip":
|
||||||
|
return 0, "192.168.2.101 dev enp4s0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n", ""
|
||||||
|
return (
|
||||||
|
0,
|
||||||
|
'{"level": "ok", "mode": "AUTO", "enabled": true}',
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(pf, "_run", fake_run)
|
||||||
|
# USB 检查读真 sysfs(真机上可能卡死的正是它)→ 这里注入假 reader
|
||||||
|
monkeypatch.setattr(pf, "check_usb", lambda *a, **k: Check("usb", "USB", pf.LEVEL_OK, ["正常"]))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pf, "check_v4l", lambda *a, **k: Check("v4l", "触觉", pf.LEVEL_OK, ["正常"])
|
||||||
|
)
|
||||||
|
|
||||||
|
site = tmp_path / "site.yaml"
|
||||||
|
site.write_text(
|
||||||
|
"robot:\n"
|
||||||
|
" sn: Rizon4-000089\n"
|
||||||
|
" robots:\n"
|
||||||
|
" - sn: Rizon4-000089\n"
|
||||||
|
" arm: left\n"
|
||||||
|
" ip: 192.168.2.101\n"
|
||||||
|
" iface: enp4s0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
rep = pf.run_all(site, "Rizon4-000089", rdk_python=sys.executable)
|
||||||
|
assert isinstance(rep, Report)
|
||||||
|
assert rep.checks, "至少要有几项结论"
|
||||||
|
assert any(c.key == "network" for c in rep.checks), "网络项没跑到说明中途崩了"
|
||||||
|
assert any(c.key == "arm" for c in rep.checks), "机械臂项没跑到说明中途崩了"
|
||||||
Reference in New Issue
Block a user