体检判据硬化:sysfs 读取带超时、复位命令不再写成"要密码"

两处都是「体检工具自己失效」——本仓库最贵的那类坑:判据看起来在跑,其实
结论是假的或者干脆没有结论。

scripts/lib/usb_health.py
  Hub 在内核里卡死时,读它的 sysfs 属性会**永久阻塞**(实测
  /sys/bus/usb/devices/1-3.4/product 读 20 秒不返回)。于是体检脚本自己挂住:
  本该报出故障的那一次,恰恰什么都不输出。新增 SysfsReader —— 所有 sysfs 读取
  带 1s 超时,卡死的路径只尝试一次并记入 blocked(Python 杀不掉阻塞在系统调用
  里的线程,不记就会在 --watch 反复采样时攒出几十个),blocked 映射回父设备
  并按 error 报出「给这个 Hub 断电重启 / 改插主板直连」。

scripts/lib/v4l_health.py
  · 提示语写「到真终端里带密码跑 orisys-usb-reset」,与实际不符:sudoers 对这个
    脚本本来就是 NOPASSWD 白名单(`sudo -n -l` 可见;orisys_tactile.py 的自动
    复位用的也是 sudo -n)。现场按这句会以为无人值守时做不了这件事。
    改成 `sudo -n /usr/local/bin/orisys-usb-reset <devpath>` 并说明免密来源。
  · wedged 的处置写反了:以前是「物理重插;reset 只在没完全卡死时有效」,
    而设备还在 sysfs 里正是 unbind/bind 能生效的前提,先说 reset。
  · main() 增加可注入的 v4l_root/usb_root,用例不再读真机 sysfs。

测试:test_main_exit_code_is_nonzero_on_error 以前直接读真 sysfs,本机这只
相机没插(unplugged)时会变红 —— 它本该只在 wedged 下断言复位提示。改为注入
临时 sysfs(设备在总线上、无 /dev/video*),用例变成确定性的。
test_usb_health + test_v4l_health 共 28 项通过。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-09-28 10:42:37 +08:00
parent 98f4277849
commit 0fadd26ca2
4 changed files with 239 additions and 24 deletions
+16 -7
View File
@@ -15,8 +15,12 @@
``sudo /usr/local/bin/orisys-usb-reset <devpath>``(unbind/bind 白名单脚本)
复位 —— 若 unbind 成功而 bind 卡住,就会变成现在这个状态:
``/sys/bus/usb/devices/1-3.4.1`` 还在、接口还挂在 ``uvcvideo`` 上,
但 ``/dev/video*`` 一个都没有。**软件侧已经没救了**,只能:
在真终端里带密码跑一次 ``orisys-usb-reset``,或拔插一次该相机。
但 ``/dev/video*`` 一个都没有。**软件侧重连已经没救**,只能复位:
``sudo -n /usr/local/bin/orisys-usb-reset <devpath>``。
这条路径**不需要密码** —— sudoers 已对这一个脚本配了 NOPASSWD 白名单
(``orisys_tactile.py`` 里的自动复位也是用 ``sudo -n`` 调的),
所以无人值守时可以直接跑;真要输密码说明白名单被改掉了。
复位仍不行(unbind 都不响应)就只剩拔插一次该相机。
3. ``可读但无帧`` —— 节点在、open 得到,但一帧都读不出来
(日志里 ``select() timeout``)。→ 多半是同一条 USB2 总线被别的流占满;
@@ -112,9 +116,10 @@ class Check:
if self.state == "wedged":
return (
f"{self.name}:设备在总线上({self.devpath})但**没有 /dev/video* 节点** "
"—— 内核级卡死,软件侧救不回来。到真终端里跑 "
f"`sudo /usr/local/bin/orisys-usb-reset {self.devpath}`(要密码),"
"或把这只相机拔插一次;然后重启触觉节点。"
"—— 内核级卡死,软件侧重连救不回来。跑 "
f"`sudo -n /usr/local/bin/orisys-usb-reset {self.devpath}` 复位"
"(免密,sudoers 白名单;这条本来就是给无人值守用的),"
"复位仍不行就拔插一次这只相机;然后重启触觉节点。"
)
return f"{self.name}:没有插({self.by_path} 不存在)—— 插线/换 USB 口。"
@@ -148,7 +153,11 @@ def check_devices(
return out
def main(argv: Optional[Sequence[str]] = None) -> int:
def main(
argv: Optional[Sequence[str]] = None,
v4l_root: Path = V4L_BY_PATH,
usb_root: Path = SYS_USB,
) -> int:
ap = argparse.ArgumentParser(description="V4L2 触觉相机存在性体检")
ap.add_argument(
"--dev",
@@ -164,7 +173,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
for item in args.dev:
name, _, path = str(item).partition("=")
pairs.append((name.strip() or "v4l", path.strip()))
checks = check_devices(pairs)
checks = check_devices(pairs, v4l_root=v4l_root, usb_root=usb_root)
if args.json:
print(json.dumps([asdict(c) for c in checks], ensure_ascii=False, indent=2))