Files
ros_contorl/scripts/lib/v4l_health.py
T
arm_dev bb6f792922 复位命令必须带 timeout:实测它自己会永久挂住,还留下占着 USB 的僵尸进程
上一版把触觉 wedged 的处置改成「首选 `sudo -n orisys-usb-reset`(免密,无人值守
可直接跑)」。真跑了一次,发现这句话是错的:

    sudo -n /usr/local/bin/orisys-usb-reset 1-3.4.1

跑了 **155s 都没返回**,进程停在 `S` 状态(卡在写
`/sys/bus/usb/drivers/usb/unbind`),`timeout 90` 都收不掉它 —— 现场留下了三个
残留进程。对无人值守来说这是最坏的结果:那一轮在这里永久卡死,而且这个进程还
占着 USB,后面重试触觉只会继续失败。

复位之后 `1-3.4.1` 直接从 sysfs **消失**了(复位前它还在总线上、只是没有
/dev/video*)—— 也就是设备比复位前掉得更彻底。所以这条命令**不是**"跑一下试试"
那种安全操作,必须当有副作用的操作对待。

改动:
· 两处给现场的命令一律写成 `timeout 15 sudo -n /usr/local/bin/orisys-usb-reset
  <devpath>`,并把「不加 timeout 会永久卡住 + 留僵尸进程」写进提示原文。
  这与 `orisys_tactile.py` 的自动自愈口径一致(那里本来就是 `timeout=15.0`,
  所以自动路径不受影响,只有手工执行的建议是错的)。
· 复位后设备仍不在总线上(或消失)→ 明确归到**物理**处置:拔插该相机 /
  给这个 Hub 断电重启。

测试:test_v4l_health 新增两条 —— 断言给出的复位命令里含 `timeout 15`,
以及提示里必须写出"会挂住"这件事(只给命令不说风险,人就会裸跑它)。
全仓库 1119 passed / 1 skipped。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 10:46:06 +08:00

200 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""触觉相机(V4L2)的设备存在性体检 —— 分清三种"没有图像"。
为什么要分三种(2026-09-28 现场,代价是半小时)
-----------------------------------------------
左指触觉那次故障,症状只有一个:"``/tactile/left/status`` 一条都不发"。
但底层其实是三种**完全不同**的情况,处理方式也完全不同:
1. ``未插`` —— ``/dev/v4l/by-path/...`` 这个软链就不存在。
→ 作用:插线/换口(物理)。
2. ``内核卡死``(``wedged``)—— **在总线上、但拿不到 /dev/video* 节点**。
实测路径:相机先出现"开得开、读不到帧"(``select() timeout`` 刷屏),
重连逻辑反复 open 同一个设备之后,设备进入内核级卡死;用框架自带的
``sudo /usr/local/bin/orisys-usb-reset <devpath>``(unbind/bind 白名单脚本)
复位 —— 若 unbind 成功而 bind 卡住,就会变成现在这个状态:
``/sys/bus/usb/devices/1-3.4.1`` 还在、接口还挂在 ``uvcvideo`` 上,
但 ``/dev/video*`` 一个都没有。**软件侧重连已经没救**,只能复位:
timeout 15 sudo -n /usr/local/bin/orisys-usb-reset <devpath>
这条路径**不需要密码** —— sudoers 已对这一个脚本配了 NOPASSWD 白名单
(``orisys_tactile.py`` 里的自动复位也是用 ``sudo -n`` 调的),
所以无人值守时可以直接跑;真要输密码说明白名单被改掉了。
**必须带 ``timeout``**:2026-09-28 实测 ``sudo -n ... 1-3.4.1`` 跑了
155s 都不返回(卡在写 ``/sys/bus/usb/drivers/usb/unbind``,进程停在 ``S``
状态),而且复位之后 ``1-3.4.1`` 直接从 sysfs **消失**了 —— 设备比复位前
更彻底地掉了。没有 ``timeout`` 的话,无人值守的那一轮会永久卡在这一句上、
还留下一个占着 USB 的僵尸进程。``orisys_tactile.py`` 的自动自愈本来就是
用 ``timeout=15.0`` 调的,手工也照同一口径。
复位走完设备仍不在总线上(或干脆消失)→ 已经是**物理**问题,只剩拔插该
相机、或给这个 Hub 断电重启。
3. ``可读但无帧`` —— 节点在、open 得到,但一帧都读不出来
(日志里 ``select() timeout``)。→ 多半是同一条 USB2 总线被别的流占满;
把抢带宽的设备(顶置 D435)挪到 USB3 口通常就好了。
把这三者混成一句"触觉没数据",就会去查错的方向:查 SDK、查 ros2 话题、
查配置。所以这里给出**唯一一处**判定口径。
"""
from __future__ import annotations
import argparse
import json
import os
import re
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import List, Optional, Sequence
V4L_BY_PATH = Path("/dev/v4l/by-path")
SYS_USB = Path("/sys/bus/usb/devices")
# /dev/v4l/by-path/pci-0000:00:14.0-usb-0:3.4.1:1.0-video-index0
# │ │ └── 接口 1.0
# │ └── USB 端口链 3.4.1 ← 我们只认这一段
# └── 根 Hub 口(不是 USB 总线号!)
# sysfs 里的设备名是 ``<bus>-3.4.1``,bus 号(1 还是 2)取不到的时候不能瞎猜,
# 所以下面一律用 ``*-3.4.1`` 去 glob —— 端口链在同一条 PCI 控制器上是唯一的。
_BY_PATH_RE = re.compile(r"usb-\d+:(\d+(?:\.\d+)*):")
def parse_usb_portchain(by_path: str) -> str:
"""从 by-path 软链名里取出**端口链**(``3.4.1``);取不到返回空串。"""
m = _BY_PATH_RE.search(str(by_path))
return m.group(1) if m else ""
def find_usb_devpath(portchain: str, root: Path = SYS_USB) -> str:
"""端口链 → sysfs 里的完整设备名(``1-3.4.1``),给 orisys-usb-reset 用。
取不到(设备真的不在总线上)返回空串。**不要**拿 ``Mode.IDLE`` 猜 bus 号。
"""
if not portchain or not root.is_dir():
return ""
hits = sorted(p.name for p in root.glob(f"*-{portchain}") if p.is_dir())
return hits[0] if hits else ""
def usb_device_present(portchain: str, root: Path = SYS_USB) -> bool:
"""该端口链上有没有 USB 设备(不管内核有没有给它 /dev/video*)。"""
return bool(find_usb_devpath(portchain, root))
def classify(video_nodes: Sequence[str], usb_present: bool) -> str:
"""``ok`` / ``wedged`` / ``unplugged`` —— 见模块开头那三种情况。"""
if video_nodes:
return "ok"
return "wedged" if usb_present else "unplugged"
def video_nodes_for(by_path: str, root: Path = V4L_BY_PATH) -> List[str]:
"""该 by-path 前缀下实际存在的 /dev/video* 节点(真读文件系统)。
比的是**文件名**而不是整条路径:``root`` 是可注入的(测试用假目录),
拿整条路径去 startswith 会永远匹配不上 —— 那种写法会让这个检查
"永远通过",比不检查更坏。
"""
if not root.is_dir():
return []
stem = Path(str(by_path).rsplit("-video-index", 1)[0]).name
out: List[str] = []
for entry in root.iterdir():
if entry.name.startswith(stem) and os.path.exists(entry):
out.append(str(entry))
return sorted(out)
@dataclass(frozen=True)
class Check:
name: str
by_path: str
devpath: str
state: str
nodes: List[str]
@property
def level(self) -> str:
return {"ok": "ok", "wedged": "error", "unplugged": "error"}[self.state]
def text(self) -> str:
if self.state == "ok":
return f"{self.name}:正常({len(self.nodes)} 个节点)"
if self.state == "wedged":
return (
f"{self.name}:设备在总线上({self.devpath})但**没有 /dev/video* 节点** "
"—— 内核级卡死,软件侧重连救不回来。跑 "
f"`timeout 15 sudo -n /usr/local/bin/orisys-usb-reset {self.devpath}` 复位"
"(免密,sudoers 白名单;`timeout` **别省** —— 实测这一句会卡在写 "
"unbind 上不返回,无人值守时会永久卡住并留下占 USB 的僵尸进程)。"
"复位后设备仍不在(或从 sysfs 消失)就是物理问题了:拔插该相机,"
"或给这个 Hub 断电重启,然后重启触觉节点。"
)
return f"{self.name}:没有插({self.by_path} 不存在)—— 插线/换 USB 口。"
def check_devices(
pairs: Sequence[tuple[str, str]],
v4l_root: Path = V4L_BY_PATH,
usb_root: Path = SYS_USB,
) -> List[Check]:
"""``pairs`` = [(名字, by-path 或空)],返回每只的体检结论。
两个 root 都可注入,测试不需要真机(也就不会因为环境不同而"永远通过")。
"""
out: List[Check] = []
for name, by_path in pairs:
if not by_path:
out.append(Check(name, "", "", "unplugged", []))
continue
nodes = video_nodes_for(by_path, v4l_root)
portchain = parse_usb_portchain(by_path)
devpath = find_usb_devpath(portchain, usb_root)
out.append(
Check(
name=name,
by_path=by_path,
devpath=devpath or portchain,
state=classify(nodes, bool(devpath)),
nodes=nodes,
)
)
return out
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",
action="append",
default=[],
metavar="NAME=PATH",
help="名字=by-path,可重复",
)
ap.add_argument("--json", action="store_true")
args = ap.parse_args(argv)
pairs = []
for item in args.dev:
name, _, path = str(item).partition("=")
pairs.append((name.strip() or "v4l", path.strip()))
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))
for c in checks:
mark = {"ok": "OK ", "error": "FAIL"}[c.level]
print(f" [{mark}] {c.text()}")
return 1 if any(c.level == "error" for c in checks) else 0
if __name__ == "__main__":
raise SystemExit(main())