stouchinglhh Claude Code commited on
Commit
693e2e6
·
1 Parent(s): 2535d68

collector: 更新采集工程至 v1.3.8(DECXIN 相机归属与命名两处修复。v1.3.7:单插的 DECXIN 在夹爪 rig 插着时从设备面板消失,两层独立根因——v1.3.0 的 VID/PID 一刀切改为按 USB 根端口判归属,且 list_v4l_devices 改按物理 USB 设备分组,救回被 by-id 链接名顶掉的那颗孤儿。v1.3.8:同型号两颗 DECXIN 的 by-id 链接归属会在重枚举时翻转导致面板设备消失又出现并丢名字,判定前缀被共用后两颗都退拓扑路径,判据刻意不是「数前缀重复」而是「没链接那台的(厂商,型号)与有链接那台相同」。两版各配套回归测试,真机验证通过)

Browse files
collector/README.md CHANGED
@@ -1,6 +1,6 @@
1
  # collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
2
 
3
- ![Version](https://img.shields.io/badge/version-1.3.6-blue)
4
  ![Python](https://img.shields.io/badge/python-3.12-blue)
5
  ![License](https://img.shields.io/badge/license-TBD-lightgrey)
6
 
@@ -375,6 +375,63 @@ are documented in [docs/index.md](docs/index.md#开发约定).
375
 
376
  ### Changelog
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  - **v1.3.6** — the Lite edition records the UMI gripper, and every SLAM point now
379
  carries the host timestamp of the frame it came from.
380
  **Lite + gripper:** the Lite edition did not record the gripper at all; that
@@ -1029,6 +1086,43 @@ i18n 文案经 `tr()` 翻译、PyQt5 信号参数用 `object` 封送大整数、
1029
 
1030
  ### 更新记录
1031
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  - **v1.3.6** — 极简版能录 UMI 夹爪了;每个 SLAM 点带上了它那张取样帧的宿主时刻。
1033
  **极简版 + 夹爪**:极简版此前完全不录夹爪,本次推翻该排除。夹爪整条链(力 /
1034
  10×10×3 力矩阵 / 夹爪状态 / SLAM 轨迹)全部落盘,但界面**不显示**任何夹爪
 
1
  # collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
2
 
3
+ ![Version](https://img.shields.io/badge/version-1.3.8-blue)
4
  ![Python](https://img.shields.io/badge/python-3.12-blue)
5
  ![License](https://img.shields.io/badge/license-TBD-lightgrey)
6
 
 
375
 
376
  ### Changelog
377
 
378
+ - **v1.3.8** — fixes a leftover from v1.3.7: the two DECXINs' by-id link
379
+ **ownership flips on re-enumeration**. Both report serial `01.00.00`, so both
380
+ want the same link name, only one gets it — and *which* one depends on
381
+ registration order. v1.3.7 pushed only the **link-less** camera down to the
382
+ topology path and left the link holder on by-id, so when the link flipped
383
+ both cameras changed key at once and the bug survived. Caught live in
384
+ `logs/main.log`: `Connected: DECXIN_head` immediately followed by
385
+ `Disconnected: DECXIN DECXIN CAMERA` — the panel drops the device along with
386
+ the name the user gave it. `list_v4l_devices()` now reports
387
+ `by_id_ambiguous`, and **both** cameras fall back to the topology path, so
388
+ each keeps a single key whoever wins the link.
389
+ The detection deliberately is *not* "count duplicate prefixes": at any
390
+ instant only one camera holds a link, so the other never enters the count and
391
+ that check would never fire. The rule is instead "a link-less physical device
392
+ whose (vendor, model) matches a **linked** one" — two cameras of the same
393
+ model with **different** serials each keep their own link and are not
394
+ downgraded. `by_id_path` is untouched (still a real path or `None`;
395
+ `ui/lite_window.py` opens the device with it). One-time cost: the standalone
396
+ DECXIN keys as `uvc:usb-1-5`, so the `DECXIN_head` entry saved under the old
397
+ by-id-shaped key no longer matches — rename it once.
398
+ - **v1.3.7** — a standalone DECXIN camera no longer disappears from the device
399
+ panel while the gripper rig is plugged in.
400
+ **Two independent root causes — fixing only the first looks like it works
401
+ until the rig is plugged in again:**
402
+ (1) v1.3.0 added `1bcf:2d4f` to the gripper-component blacklist wholesale, so
403
+ the camera could never enter the generic UVC list — while the "gripper" group
404
+ that should have claimed it requires the ESP32 control board to be present.
405
+ Rejected by both, it vanished. `data/device_names.json` still carries
406
+ `uvc:usb-DECXIN_DECXIN_CAMERA_01.00.00` = `"DECXIN_head"`, proof that plugged
407
+ in alone it was a plain UVC camera. Ownership is now decided by **USB root
408
+ hub**: the board sits at `1-2.2.1` and the rig's DECXIN at `1-2.2.2` (both
409
+ root hub `1-2`), while a standalone DECXIN sits at `1-5` — only a DECXIN on
410
+ the board's root hub counts as part of the rig. `gripper_root_hubs()` derives
411
+ that set from the board's tty path, and a camera whose own root hub cannot be
412
+ read is conservatively excluded (better hidden than opened twice against the
413
+ rig). Sightac and the FT602 are *not* released: the former has no standalone
414
+ use, the latter is already covered by `is_sdk`.
415
+ (2) more fundamentally, **udev's by-id link names collide**. The two DECXINs
416
+ report identical vendor / model / serial strings (`DECXIN` / `DECXIN CAMERA` /
417
+ `01.00.00`), so their by-id link names are identical too — and a link name is
418
+ unique, so whichever registers last wins and the other becomes an orphan with
419
+ no link at all. `core.camera.list_v4l_devices()` used **by-id as its
420
+ enumeration entry point**, so the orphan never even reached the filter. It now
421
+ groups by **physical USB device** (`_v4l_nodes_by_physical_device`, keyed on
422
+ the topology path such as `1-5`, falling back to the sysfs realpath for
423
+ non-USB nodes) and keeps the lowest stream index per device; with no link the
424
+ display name falls back to the USB vendor/product strings, which decode to the
425
+ same name the by-id path produced. `by_id_path` stays "a real path, or None" —
426
+ `ui/lite_window.py` uses it as the path to *open* the device, so it must never
427
+ be fabricated. Device keys now degrade from the by-id prefix to
428
+ `usb-<topology path>` (also stable across reboots) before falling back to the
429
+ drifting video index. Known side effect: with both cameras plugged in the
430
+ standalone one keys as `uvc:usb-1-5` and does **not** inherit the saved
431
+ `DECXIN_head` name (that key is by-id-shaped and now belongs to the filtered
432
+ rig camera); name it once in each configuration if it must stay stable. The
433
+ Lite edition now scans the gripper first so it has the root hubs before
434
+ filtering UVC.
435
  - **v1.3.6** — the Lite edition records the UMI gripper, and every SLAM point now
436
  carries the host timestamp of the frame it came from.
437
  **Lite + gripper:** the Lite edition did not record the gripper at all; that
 
1086
 
1087
  ### 更新记录
1088
 
1089
+ - **v1.3.8** — 修 v1.3.7 的遗留:两颗 DECXIN 的 by-id 链接**归属会在重新枚举时
1090
+ 翻转**。两颗序列号都是 `01.00.00`,都要同一个链接名,而只有一颗拿得到——**谁
1091
+ 拿到取决于注册顺序**。v1.3.7 只把**没链接**那颗压到拓扑路径、拿到链接那颗仍用
1092
+ by-id,于是链接一翻转两颗一起换 key,问题照旧。真机 `logs/main.log` 抓到:
1093
+ `Connected: DECXIN_head` 紧跟 `Disconnected: DECXIN DECXIN CAMERA`——面板上
1094
+ 设备连同用户起的名字一起消失。现由 `list_v4l_devices()` 报 `by_id_ambiguous`,
1095
+ **两颗都退拓扑路径**,谁拿到链接都各守一个 key。
1096
+ 判据**刻意不是「数前缀重复」**:任一时刻只有一颗真拿到链接,另一颗压根进不了
1097
+ 计数,那么数永远数不出来。改为「一台没链接的物理设备,其 (厂商, 型号) 与另一台
1098
+ **有链接**的相同」——同型号但**序列号不同**的两台各有各的链接,不会被误判降级。
1099
+ `by_id_path` 不动(仍是「真路径或 None」,`ui/lite_window.py` 拿它开设备)。
1100
+ 一次性代价:单插那颗 DECXIN 的 key 变成 `uvc:usb-1-5`,挂在旧 by-id 形式 key
1101
+ 下的 `DECXIN_head` 不再匹配——重命名一次即可。
1102
+ - **v1.3.7** — 单插的 DECXIN 相机在夹爪 rig 插着时不再从设备面板消失。
1103
+ **两层独立根因,只修第一层会看起来「好了」、rig 一插就复发**:
1104
+ ①v1.3.0 把 `1bcf:2d4f` 整颗加进夹爪组件黑名单,它永远进不了通用 UVC
1105
+ 列表;而本该收它的「夹爪」分组又要求 ESP32 控制板在场——两边都不收就消失了。
1106
+ `data/device_names.json` 里躺着 `uvc:usb-DECXIN_DECXIN_CAMERA_01.00.00`
1107
+ = `"DECXIN_head"`,证明它单插时本来就是普通 UVC 相机。改按 **USB 根端口**
1108
+ 判归属:控制板在 `1-2.2.1`、rig 那颗 DECXIN 在 `1-2.2.2`(同属根端口 `1-2`),
1109
+ 单插那颗在 `1-5`——只有挂在控制板根端口下的 DECXIN 才算 rig 的。`gripper_root_hubs()`
1110
+ 从控制板的 tty 路径取该集合;相机自身根端口取不到时**保守排除**(宁可藏,
1111
+ 不可与 rig 双开)。Sightac 与 FT602 不放开:前者单插无用,后者另有 `is_sdk`
1112
+ 兜底。
1113
+ ②更底层:**udev 的 by-id 链接名会撞**。两颗 DECXIN 的厂商/型号/序列号字符串
1114
+ 完全相同(`DECXIN` / `DECXIN CAMERA` / `01.00.00`),by-id 链接名也就完全相同
1115
+ ——而链接名唯一,**只有后注册那颗有链接,另一颗成孤儿**。
1116
+ `core.camera.list_v4l_devices()` 原本**以 by-id 为枚举入口**,孤儿压根进不了
1117
+ 枚举,轮不到过滤器。改按**物理 USB 设备**分组(`_v4l_nodes_by_physical_device`,
1118
+ 键取拓扑路径如 `1-5`,非 USB 节点退回 sysfs realpath),每个物理设备只留最小
1119
+ 流索引;无链接时显示名退回 USB 厂商+型号字符串(与 by-id 解码同名)。
1120
+ `by_id_path` 保持「有就是真路径、没有就是 None」——`ui/lite_window.py` 拿它当
1121
+ **打开设备**的路径,不能伪造。设备 key 从 by-id 前缀退到 `usb-<拓扑路径>`
1122
+ (同样跨重启稳定),最后才退到会漂移的 video 索引。已知副作用:两套都插时
1123
+ 单插那颗 key 是 `uvc:usb-1-5`、**不会自动接上旧的 `DECXIN_head`**(那个 key
1124
+ 是 by-id 形式、此时归已被过滤的 rig 那颗);要名字稳定就在两种配置下各命名
1125
+ 一次。极简版改为先扫夹爪、拿到根端口再过滤 UVC。
1126
  - **v1.3.6** — 极简版能录 UMI 夹爪了;每个 SLAM 点带上了它那张取样帧的宿主时刻。
1127
  **极简版 + 夹爪**:极简版此前完全不录夹爪,本次推翻该排除。夹爪整条链(力 /
1128
  10×10×3 力矩阵 / 夹爪状态 / SLAM 轨迹)全部落盘,但界面**不显示**任何夹爪
collector/config/__init__.py CHANGED
@@ -3,21 +3,26 @@
3
  版本号集中定义于此:settings.APP_VERSION 及其他模块均从这里取值。
4
  """
5
 
6
- __version__ = "1.3.6"
7
 
8
- # v1.3.6 —— 夹爪并入极简版(只录不显)+ SLAM 轨迹点带上取样帧的宿主时刻戳。
9
- # 1) 极简版新增夹爪:力 / 10×10×3 力矩阵 / 夹爪状态 / SLAM 轨迹照常落盘,界面
10
- # 不显示任何夹爪信息,夹爪 RGB 直接当主视频源。载荷是硬约束——Linux 包把
11
- # ~460MB 原生栈当必需资源下发,start_lite.sh 逐项校验、缺则报 [错误 B] 拒启
12
- # (老机器升级会被拦住而不是跑一半才炸);Windows 包有意不带、靠载荷缺失降级。
13
- # 力矩阵编码搬到 core/gripper_codec.py(数据契约只此一份,UI 侧 re-export)。
14
- # 2) SLAM 点新增 observation.{prefix}slam_trajectory_ns:native 在 stereoCallback
15
- # 入口取一次 CLOCK_MONOTONIC,随帧走到 stdout 的 Host:(<ns>),与点列表锁步落盘。
16
- # 真机验收(episode-085/086):点数=戳数、零全 0 行、最近邻残差中位 6.9ms
17
- # (按行号配对为 83ms)。旧 native 二进制不打印该字段,照旧可读。
18
- # 另回收 native 树 339MB 被解引用成实体副本的 .so(tools/dedup_gripper_native.py)。
 
19
  #
20
  # 历史版本一行一条,完整说明见 README 的「更新记录」:
 
 
 
 
21
  # v1.3.5 相机服务静默卡死自愈;夹爪扫描配对改走 ESP32 NVS 序列号
22
  # v1.3.4 新夹爪接入即自动生成运行标定并连接,不再需要人工两步
23
  # v1.3.3 力矩阵 / 触觉力与 RGB 视频的时间戳对齐(补 capture_ns 稀疏列)
 
3
  版本号集中定义于此:settings.APP_VERSION 及其他模块均从这里取值。
4
  """
5
 
6
+ __version__ = "1.3.8"
7
 
8
+ # v1.3.8 —— 修 v1.3.7 遗留:同型号同序列号两颗(真机两颗 DECXIN:序列号都是
9
+ # 01.00.00)的 by-id 链接**归属会在重新枚举时翻转**(谁后注册谁拿到),于是
10
+ # 同一台相机在 `uvc:usb-1-5` 与 `uvc:usb-DECXIN_..._01.00.00` 两个 key 之间
11
+ # 跳 —— 面板表现为设备消失又出现(真机 logs/main.log 抓到 `Connected:
12
+ # DECXIN_head` 紧跟 `Disconnected: DECXIN DECXIN CAMERA`)、用户起的名字丢失。
13
+ # v1.3.7 只让「拿不到链接」那颗退拓扑路径,拿到链接那颗仍用 by-id ⇒ 翻转时
14
+ # 两颗一起跳,问题没解决。现改为:判定前缀被共用后**两颗都退拓扑路径**。
15
+ # 判据不能是「数前缀重复」—— 任一时刻只有一台真拿到链接,另一台压根进不了
16
+ # 计数;改为「一台没链接的物理设备,其 (厂商, 型号) 与另一台有链接的相同」
17
+ # (同型号但序列号不同的两台各有各的链接,不会被误判降级)。
18
+ # 代价:那颗单插 DECXIN 的 key 从 by-id 形式变成 `uvc:usb-1-5`,data/
19
+ # device_names.json 里挂在旧 key 下的名字需重命名一次(旧 key 已不再产生)。
20
  #
21
  # 历史版本一行一条,完整说明见 README 的「更新记录」:
22
+ # v1.3.7 单插的 DECXIN 相机在夹爪 rig 插着时从设备面板消失。**两层独立根因**:
23
+ # v1.3.0 的 1bcf:2d4f 一刀切黑名单(改按 USB 根端口判归属)+ by-id
24
+ # 链接被同型号相机顶掉(改按物理 USB 设备分组枚举)
25
+ # v1.3.6 夹爪并入极简版(只录不显)+ SLAM 点带上取样帧的宿主时刻戳
26
  # v1.3.5 相机服务静默卡死自愈;夹爪扫描配对改走 ESP32 NVS 序列号
27
  # v1.3.4 新夹爪接入即自动生成运行标定并连接,不再需要人工两步
28
  # v1.3.3 力矩阵 / 触觉力与 RGB 视频的时间戳对齐(补 capture_ns 稀疏列)
collector/core/camera.py CHANGED
@@ -294,34 +294,149 @@ def _discover_indices(max_index: int = 8) -> list:
294
  return sorted(all_indices)
295
 
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  def list_v4l_devices(max_index: int = 16) -> list:
298
  """sysfs 只读枚举 V4L2 设备(不 open、不 test_read,轮询安全)。
299
 
300
- 以 /dev/v4l/by-id 分组识别物理设备,每个物理设备只保留主视频流
301
- (最小索引)。返回列表每项:
302
- {video_index, name, serial, by_id_path, vid, pid, is_sdk}
303
- name 从 by-id 前缀解码(去 "usb-" 前缀、字段分隔符转空格);
 
 
 
 
 
 
 
 
304
  serial 启发式取前缀最后一段(仅纯字母数字且 ≥8 字符才认作序号)。
305
  被占用的设备不会被打开,仍会稳定出现在列表中。
306
  """
307
  devices = []
308
- for prefix, idx_list in sorted(_by_id_device_streams().items()):
309
- idx_list = sorted(idx_list)
310
- idx = idx_list[0] # 主视频流
311
- if idx >= max_index:
312
- continue
 
 
 
313
  if prefix.startswith("usb-"):
314
  # usb-<vendor>_<model>_<serial> → 分段转空格
315
  segments = [s for s in re.split(r"[-_]", prefix[len("usb-"):]) if s]
316
- serial = ""
317
  if segments and segments[-1].isalnum() and len(segments[-1]) >= 8:
318
  serial = segments[-1] # 序号单独显示,不进名称
319
  segments = segments[:-1]
320
  name = " ".join(segments).strip()
321
- else:
322
- name, serial = "", ""
323
  if not name:
324
- # 非 USB 前缀无法解码,回退 sysfs name 文件
 
 
 
 
325
  try:
326
  with open(f"/sys/class/video4linux/video{idx}/name") as f:
327
  name = f.read().strip()
@@ -332,7 +447,9 @@ def list_v4l_devices(max_index: int = 16) -> list:
332
  "video_index": idx,
333
  "name": name,
334
  "serial": serial,
335
- "by_id_path": _find_persistent_v4l_path(idx),
 
 
336
  "vid": vp[0] if vp else None,
337
  "pid": vp[1] if vp else None,
338
  "is_sdk": _is_sdk_device(idx),
 
294
  return sorted(all_indices)
295
 
296
 
297
+ def _usb_ident_strings(index: int) -> tuple:
298
+ """USB 设备节点的 (厂商, 型号) 字符串;取不到给空串。
299
+
300
+ 无 by-id 链接时用它拼显示名 —— 同型号同序列号两台并存会被 udev 顶掉
301
+ 链接,此时只剩 sysfs 字符串可读。
302
+ """
303
+ node = os.path.realpath(f"/sys/class/video4linux/video{index}")
304
+ for _ in range(6):
305
+ try:
306
+ with open(os.path.join(node, "product")) as f:
307
+ product = f.read().strip()
308
+ if product:
309
+ try:
310
+ with open(os.path.join(node, "manufacturer")) as f:
311
+ manufacturer = f.read().strip()
312
+ except OSError:
313
+ manufacturer = ""
314
+ return manufacturer, product
315
+ except OSError:
316
+ pass
317
+ parent = os.path.dirname(node)
318
+ if parent == node:
319
+ break
320
+ node = parent
321
+ return "", ""
322
+
323
+
324
+ def _physical_usb_path(index: int) -> Optional[str]:
325
+ """videoN 所属物理 USB 设备的拓扑路径(如 "1-5");非 USB/取不到返回 None。
326
+
327
+ 同一台相机的多个流(videoN / videoN+1)共享同一路径,可当分组键 ——
328
+ 与 by-id 不同,**同型号同序列号两台并存时不会撞**。
329
+ """
330
+ try:
331
+ node = os.path.realpath(f"/sys/class/video4linux/video{index}/device")
332
+ except OSError:
333
+ return None
334
+ for component in reversed(node.split(os.sep)):
335
+ physical = component.split(":", 1)[0]
336
+ if re.fullmatch(r"\d+-\d+(?:\.\d+)*", physical):
337
+ return physical
338
+ return None
339
+
340
+
341
+ def _v4l_nodes_by_physical_device(max_index: int) -> dict:
342
+ """{物理设备键: [video_index, ...]},覆盖 sysfs 里**所有** v4l 节点。
343
+
344
+ 键优先取 USB 拓扑路径,非 USB 节点退回 sysfs realpath,保证同一物理
345
+ 设备的多个流聚到一组。
346
+ """
347
+ groups: dict = {}
348
+ root = "/sys/class/video4linux"
349
+ try:
350
+ entries = os.listdir(root)
351
+ except OSError:
352
+ return groups
353
+ for entry in entries:
354
+ if not re.fullmatch(r"video\d+", entry):
355
+ continue
356
+ idx = int(entry[len("video"):])
357
+ if idx >= max_index:
358
+ continue
359
+ key = _physical_usb_path(idx) or os.path.realpath(
360
+ os.path.join(root, entry, "device")
361
+ )
362
+ groups.setdefault(key, []).append(idx)
363
+ return groups
364
+
365
+
366
+ def _ambiguous_by_id_prefixes(groups: dict) -> set:
367
+ """by-id 前缀不可当稳定标识的那些前缀(被多台物理设备共用)。
368
+
369
+ udev 的 by-id 链接名 = `usb-<厂商>_<型号>_<序列号>`,而链接名在一个目录
370
+ 内唯一:同型号**同序列号**的两台并存时,**只有一台真拿到链接**,另一台
371
+ by_id_path 为 None。要命的是**谁拿到取决于注册顺序**,重新枚举时会在两台
372
+ 之间翻转 ⇒ 同一台相机在两个 key(by-id 形式 / usb-<拓扑路径>)之间跳,
373
+ 面板表现为设备消失又出现、并把用户起的名字丢掉(真机 logs/main.log 抓到
374
+ `Connected: DECXIN_head` 紧跟 `Disconnected: DECXIN DECXIN CAMERA`)。
375
+
376
+ **判据不能是「数前缀重复」**:任一时刻只有一台有链接,另一台压根进不了
377
+ 计数。改为 —— 一台**没链接**的物理设备,其 (厂商, 型号) 与另一台**有
378
+ 链接**的相同,说明那台的链接是踩了它才拿到的。同型号**不同序列号**的两台
379
+ 各有各的链接、不会被误判(它们的 by-id key 仍然是稳的,不降级)。
380
+ """
381
+ linked = {} # (厂商, 型号) → [前缀, ...]
382
+ unlinked = set() # 拿不到链接的物理设备的 (厂商, 型号)
383
+ for idx_list in groups.values():
384
+ idx = min(idx_list)
385
+ ident = tuple(s.strip().lower() for s in _usb_ident_strings(idx))
386
+ if not any(ident):
387
+ continue # 读不到厂商/型号 → 无从判定,不参与(保守)
388
+ path = _find_persistent_v4l_path(idx)
389
+ if not path:
390
+ unlinked.add(ident)
391
+ continue
392
+ prefix = os.path.basename(path).rsplit("-video-index", 1)[0]
393
+ linked.setdefault(ident, []).append(prefix)
394
+ ambiguous = set()
395
+ for ident in unlinked:
396
+ ambiguous.update(linked.get(ident, ()))
397
+ return ambiguous
398
+
399
+
400
  def list_v4l_devices(max_index: int = 16) -> list:
401
  """sysfs 只读枚举 V4L2 设备(不 open、不 test_read,轮询安全)。
402
 
403
+ 按**物理 USB 设备**分组(不是 by-id):同型号同序列号两台并存时 udev
404
+ 的 by-id 链接名唯一(`usb-<厂商>_<���号>_<序列号>` 完全相同),后注册的
405
+ 那台会顶掉前一台的链接 —— 以 by-id 为枚举入口会让被顶掉那台**整个从
406
+ 面板消失**。每个物理设备只保留主视频流(最小索引)。返回列表每项:
407
+ {video_index, name, serial, by_id_path, by_id_ambiguous, usb_path,
408
+ vid, pid, is_sdk}
409
+ by_id_path 尽力而为:无对应链接(被顶掉/无 by-id 规则)时为 None,
410
+ 调用方须回退到 video_index 打开。**by_id_ambiguous 为真表示这个前缀被
411
+ 多台共用、归属会在重新枚举时翻转,不能当 key**(见
412
+ _ambiguous_by_id_prefixes);注意它只影响「能不能拿前缀当标识」,
413
+ by_id_path 本身仍是真路径、照旧可用于打开设备。name 优先从 by-id 前缀
414
+ 解码(去 "usb-" 前缀、字段分隔符转空格),无链接时回退 sysfs name 文件;
415
  serial 启发式取前缀最后一段(仅纯字母数字且 ≥8 字符才认作序号)。
416
  被占用的设备不会被打开,仍会稳定出现在列表中。
417
  """
418
  devices = []
419
+ groups = _v4l_nodes_by_physical_device(max_index)
420
+ ambiguous = _ambiguous_by_id_prefixes(groups)
421
+ for _key, idx_list in sorted(groups.items()):
422
+ idx = min(idx_list) # 主视频流
423
+ by_id_path = _find_persistent_v4l_path(idx)
424
+ prefix = (os.path.basename(by_id_path).rsplit("-video-index", 1)[0]
425
+ if by_id_path else "")
426
+ name, serial = "", ""
427
  if prefix.startswith("usb-"):
428
  # usb-<vendor>_<model>_<serial> → 分段转空格
429
  segments = [s for s in re.split(r"[-_]", prefix[len("usb-"):]) if s]
 
430
  if segments and segments[-1].isalnum() and len(segments[-1]) >= 8:
431
  serial = segments[-1] # 序号单独显示,不进名称
432
  segments = segments[:-1]
433
  name = " ".join(segments).strip()
 
 
434
  if not name:
435
+ # 无 by-id(被 udev 顶掉/无规则)→ 退回 USB 厂商+型号字符串,
436
+ # 与 by-id 解码结果同名;再取不到才用 sysfs name 文件
437
+ manufacturer, product = _usb_ident_strings(idx)
438
+ name = " ".join(p for p in (manufacturer, product) if p).strip()
439
+ if not name:
440
  try:
441
  with open(f"/sys/class/video4linux/video{idx}/name") as f:
442
  name = f.read().strip()
 
447
  "video_index": idx,
448
  "name": name,
449
  "serial": serial,
450
+ "by_id_path": by_id_path,
451
+ "by_id_ambiguous": prefix in ambiguous,
452
+ "usb_path": _physical_usb_path(idx),
453
  "vid": vp[0] if vp else None,
454
  "pid": vp[1] if vp else None,
455
  "is_sdk": _is_sdk_device(idx),
collector/core/device_detector.py CHANGED
@@ -147,12 +147,15 @@ def _list_uvc_devices_windows(max_index: int) -> List[DeviceInfo]:
147
  return infos
148
 
149
 
150
- def _list_uvc_devices(max_index: int = settings.DEVICE_SCAN_MAX_INDEX) -> List[DeviceInfo]:
 
151
  """UVC 网络摄像头(排除 RealSense UVC 节点、FTDI SDK 设备与夹爪组件)。
152
 
153
  夹爪组件的 Sightac/DECXIN/FTDI 相机由 libuvc 服务或 Fays 桥接独占,
154
- 绝不作为通用 UVC 相机出现。Linux: sysfs 只读枚举(/dev/v4l/by-id
155
- 分组,轮询安全)。Windows: pygrabber DirectShow 枚举。
 
 
156
  """
157
  if os.name == "nt":
158
  return _list_uvc_devices_windows(max_index)
@@ -162,12 +165,19 @@ def _list_uvc_devices(max_index: int = settings.DEVICE_SCAN_MAX_INDEX) -> List[D
162
  continue
163
  if d.get("is_realsense"):
164
  continue
165
- if _is_gripper_component_camera(d):
166
  continue
167
- # key 用 by-id 前缀(跨插拔稳定);无 by-id 时退化为索引
 
 
 
 
 
168
  prefix = str(d["video_index"])
169
- if d.get("by_id_path"):
170
  prefix = os.path.basename(d["by_id_path"]).rsplit("-video-index", 1)[0]
 
 
171
  infos.append(DeviceInfo(
172
  key=f"uvc:{prefix}",
173
  kind="uvc",
@@ -436,7 +446,11 @@ def detect_devices(max_index: int = settings.DEVICE_SCAN_MAX_INDEX) -> List[Devi
436
  except Exception:
437
  gripper_devices = []
438
  try:
439
- devices += _list_uvc_devices(max_index)
 
 
 
 
440
  except Exception:
441
  pass
442
  try:
@@ -554,21 +568,101 @@ _GRIPPER_USB_PID = 0x1001
554
  # 夹爪组件相机(Sightac 触觉 ×2 / DECXIN RGB / Fays FT602),libuvc 服务与
555
  # Fays 桥接独占,绝不进通用 UVC 列表
556
  _GRIPPER_COMPONENT_UVC = {("0c45", "636f"), ("1bcf", "2d4f"), ("0403", "602e")}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
 
 
558
 
559
- def _is_gripper_component_camera(d: dict) -> bool:
560
- """按 VID/PID 识别夹爪组件相机;VID/PID 缺失时用 by-id 字符串兜底。"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  vid = str(d.get("vid") or "").lower()
562
  pid = str(d.get("pid") or "").lower()
563
- if vid and pid:
564
- return (vid, pid) in _GRIPPER_COMPONENT_UVC
565
- by_id = (d.get("by_id_path") or "").lower()
566
- name = (d.get("name") or "").lower()
567
- return any(
568
- token in by_id or token in name
569
- for token in ("0c45_636f", "1bcf_2d4f", "0403_602e",
570
- "sightac", "decxin", "ftdi superspeed")
571
- )
 
 
 
 
 
 
 
 
 
 
 
572
 
573
 
574
  def _list_gripper_devices() -> List[DeviceInfo]:
 
147
  return infos
148
 
149
 
150
+ def _list_uvc_devices(max_index: int = settings.DEVICE_SCAN_MAX_INDEX,
151
+ *, gripper_hubs=None) -> List[DeviceInfo]:
152
  """UVC 网络摄像头(排除 RealSense UVC 节点、FTDI SDK 设备与夹爪组件)。
153
 
154
  夹爪组件的 Sightac/DECXIN/FTDI 相机由 libuvc 服务或 Fays 桥接独占,
155
+ 绝不作为通用 UVC 相机出现;但只有与控制板同根端口的那颗才算 rig 的,
156
+ 单插的 DECXIN 按普通 UVC 放出来(见 _is_gripper_component_camera)。
157
+ Linux: sysfs 只读枚举(/dev/v4l/by-id 分组,轮询安全)。
158
+ Windows: pygrabber DirectShow 枚举。
159
  """
160
  if os.name == "nt":
161
  return _list_uvc_devices_windows(max_index)
 
165
  continue
166
  if d.get("is_realsense"):
167
  continue
168
+ if _is_gripper_component_camera(d, gripper_hubs=gripper_hubs):
169
  continue
170
+ # key 用 by-id 前缀(跨插拔稳定);同型号同序列号两台并存时 udev 的
171
+ # by-id 链接名唯一、被对方顶掉,退化为 USB 拓扑路径(同样跨重启
172
+ # 稳定),最后才退到可能漂移的 video 索引。
173
+ # by_id_ambiguous=该前缀被多台共用,**链接归属会在重新枚举时翻转**
174
+ # (谁后注册谁拿到),此时前缀本身就不再稳定 —— 拿到链接的那台也得
175
+ # 放弃它,否则同一台相机在两个 key 之间跳、面板上设备消失又出现。
176
  prefix = str(d["video_index"])
177
+ if d.get("by_id_path") and not d.get("by_id_ambiguous"):
178
  prefix = os.path.basename(d["by_id_path"]).rsplit("-video-index", 1)[0]
179
+ elif d.get("usb_path"):
180
+ prefix = f"usb-{d['usb_path']}"
181
  infos.append(DeviceInfo(
182
  key=f"uvc:{prefix}",
183
  kind="uvc",
 
446
  except Exception:
447
  gripper_devices = []
448
  try:
449
+ # rig 的组件相机(DECXIN/Sightac/FT602)归 libuvc 服务独占,不进通用
450
+ # 列表;只有与控制板同根端口的那颗才算 rig 的,单插的 DECXIN 放行。
451
+ devices += _list_uvc_devices(
452
+ max_index, gripper_hubs=gripper_root_hubs(gripper_devices)
453
+ )
454
  except Exception:
455
  pass
456
  try:
 
568
  # 夹爪组件相机(Sightac 触觉 ×2 / DECXIN RGB / Fays FT602),libuvc 服务与
569
  # Fays 桥接独占,绝不进通用 UVC 列表
570
  _GRIPPER_COMPONENT_UVC = {("0c45", "636f"), ("1bcf", "2d4f"), ("0403", "602e")}
571
+ _GRIPPER_COMPONENT_TOKENS = ("0c45_636f", "1bcf_2d4f", "0403_602e",
572
+ "sightac", "decxin", "ftdi superspeed")
573
+ # 组件里唯一有独立用途的一颗:DECXIN 单插时跑 USB-DECXIN--- 手部关键点任务
574
+ # (v1.3.0 前它是普通 UVC 相机,device_names.json 里还留着 "DECXIN_head")。
575
+ # rig 上那颗与 Sightac 一起挂在控制板的根端口下,单插那颗在别的根端口 ——
576
+ # 真机实测:控制板 1-2.2.1、rig 的 DECXIN 1-2.2.2(同属根端口 1-2),
577
+ # 单插的 DECXIN 1-5。原生 discover-uvc-config 按同一拓扑分组,把单插那颗
578
+ # 标成 ungrouped。Sightac/FT602 不放开:前者单插无用,后者另有 is_sdk 兜底。
579
+ _GRIPPER_STANDALONE_UVC = {("1bcf", "2d4f")}
580
+ _GRIPPER_STANDALONE_TOKENS = ("1bcf_2d4f", "decxin")
581
+
582
+
583
+ def _usb_root_hub_from_sysfs(sysfs_node: str) -> Optional[str]:
584
+ """从 sysfs 设备节点取 USB 根端口("1-2.2.1" → "1-2");取不到返回 None。
585
+
586
+ 与 core/gripper/devices/usb_camera_sets.py 的 _root_hub_from_physical
587
+ 同一约定。此处自带一份,是因为本模块必须能在 Windows 上 import(见
588
+ core/gripper_codec.py 的 fcntl 陷阱),不反向依赖 core.gripper。
589
+ """
590
+ try:
591
+ resolved = os.path.realpath(sysfs_node)
592
+ except OSError:
593
+ return None
594
+ for component in reversed(resolved.split(os.sep)):
595
+ physical = component.split(":", 1)[0]
596
+ match = re.fullmatch(r"(\d+-\d+)(?:\.\d+)*", physical)
597
+ if match is not None:
598
+ return match.group(1)
599
+ return None
600
+
601
+
602
+ def _v4l_root_hub(video_index) -> Optional[str]:
603
+ """V4L2 节点(/dev/videoN)所在的 USB 根端口。"""
604
+ if video_index is None:
605
+ return None
606
+ return _usb_root_hub_from_sysfs(
607
+ f"/sys/class/video4linux/video{video_index}/device"
608
+ )
609
+
610
+
611
+ def _tty_root_hub(device_path: str) -> Optional[str]:
612
+ """ttyACM 控制板所在的 USB 根端口;用来定位夹爪 rig 占了哪个根端口。"""
613
+ basename = os.path.basename(str(device_path or "").strip())
614
+ if not re.fullmatch(r"ttyACM\d+", basename):
615
+ return None
616
+ return _usb_root_hub_from_sysfs(f"/sys/class/tty/{basename}/device")
617
+
618
 
619
+ def gripper_root_hubs(gripper_devices) -> set:
620
+ """夹爪控制板占用的 USB 根端口集合;空集=本次没接 rig。
621
 
622
+ rig 的 DECXIN/Sightac 与控制板同根端口,据此把单插的 DECXIN 从夹爪
623
+ 组件的排除名单里摘出来(见 _is_gripper_component_camera)。
624
+ """
625
+ hubs = set()
626
+ for info in gripper_devices or ():
627
+ hub = _tty_root_hub(getattr(info, "address", "") or "")
628
+ if hub:
629
+ hubs.add(hub)
630
+ return hubs
631
+
632
+
633
+ def _is_gripper_component_camera(d: dict, *, gripper_hubs=None) -> bool:
634
+ """按 VID/PID 识别夹爪组件相机;VID/PID 缺失时用 by-id 字符串兜底。
635
+
636
+ gripper_hubs 是控制板占用的根端口集合(gripper_root_hubs()):
637
+ None → 未知,一律按组件处理(保守,维持旧契约)
638
+ set() → 没接 rig,没有 libuvc 服务独占 → 可独立使用的 DECXIN 放行
639
+ {"1-2"} → 只放行不在这些根端口下的 DECXIN,即单插的那颗
640
+ 相机自身根端口取不到时同样按组件处理 —— 宁可藏,不可与 rig 双开。
641
+ """
642
+ releasable = gripper_hubs is not None
643
+ hubs = set(gripper_hubs or ())
644
  vid = str(d.get("vid") or "").lower()
645
  pid = str(d.get("pid") or "").lower()
646
+ if not (vid and pid):
647
+ by_id = (d.get("by_id_path") or "").lower()
648
+ name = (d.get("name") or "").lower()
649
+ if releasable and any(
650
+ token in by_id or token in name
651
+ for token in _GRIPPER_STANDALONE_TOKENS
652
+ ):
653
+ hub = _v4l_root_hub(d.get("video_index"))
654
+ if hub is not None and hub not in hubs:
655
+ return False
656
+ return any(
657
+ token in by_id or token in name
658
+ for token in _GRIPPER_COMPONENT_TOKENS
659
+ )
660
+ key = (vid, pid)
661
+ if key in _GRIPPER_STANDALONE_UVC and releasable:
662
+ hub = _v4l_root_hub(d.get("video_index"))
663
+ if hub is not None and hub not in hubs:
664
+ return False
665
+ return key in _GRIPPER_COMPONENT_UVC
666
 
667
 
668
  def _list_gripper_devices() -> List[DeviceInfo]:
collector/docs/config.md CHANGED
@@ -117,7 +117,7 @@ config/ 是应用全局配置、国际化与版本号所在的配置包。版本
117
  **JSON 字段约定**(由本模块的读写函数定义):
118
 
119
  - `data/server_config.json`:`server_url`(str)、`username`(str)、`password`(str)、`upload_auto_sync`(bool,缺省 `True`)、`upload_delete_after`(bool,缺省 `False`)。
120
- - `data/device_names.json`:key = `DeviceInfo.stable_key`(形如 `"uvc:{by-id前缀}"` / `"d435:{serial}"` / `"ble:{MAC}"` 等),value = `{"name": str, "sensor"?: str}`(`sensor` 仅 `data_ble` 设备用,绑定 parquet 列名 `right_glove`/`left_glove`,按 MAC 持久化);旧版纯字符串自动升级为 `{"name": …}`。
121
  - `data/device_params.json`:key 同 `stable_key`,value = `{"exposure": {"auto": bool, "value": float}, "original": {同结构}}`;`auto=True` 时 `value` 忽略;`original` 首次看到才写入、之后永不覆盖,供"恢复默认"回到开机原厂曝光。
122
 
123
  **调用关系**:被 `core/`(`pipeline.py`、`camera.py`、`hand_tracking.py`、`stereo_depth.py`、`device_detector.py`、`d435_camera.py`、`egodata_writer.py`、`task_record.py`、`render_engine.py`、`helpers.py`(`as _settings`)等)、`ui/`(`main_window.py`、`camera_widget.py`、`device_panel.py`、`playback_dialog.py`、`upload_dialog.py`、`task_page.py`)以及 `tools/tests/` 多个用例 import。自身仅依赖 `os` 与 `config.__version__`。
 
117
  **JSON 字段约定**(由本模块的读写函数定义):
118
 
119
  - `data/server_config.json`:`server_url`(str)、`username`(str)、`password`(str)、`upload_auto_sync`(bool,缺省 `True`)、`upload_delete_after`(bool,缺省 `False`)。
120
+ - `data/device_names.json`:key = `DeviceInfo.stable_key`(形如 `"uvc:{by-id前缀}"` / `"uvc:usb-1-5"`(无 by-id 链接、或该前缀被同型号多台共用而归属会翻转时,退到 USB 拓扑路径)/ `"d435:{serial}"` / `"ble:{MAC}"` 等),value = `{"name": str, "sensor"?: str}`(`sensor` 仅 `data_ble` 设备用,绑定 parquet 列名 `right_glove`/`left_glove`,按 MAC 持久化);旧版纯字符串自动升级为 `{"name": …}`。
121
  - `data/device_params.json`:key 同 `stable_key`,value = `{"exposure": {"auto": bool, "value": float}, "original": {同结构}}`;`auto=True` 时 `value` 忽略;`original` 首次看到才写入、之后永不覆盖,供"恢复默认"回到开机原厂曝光。
122
 
123
  **调用关系**:被 `core/`(`pipeline.py`、`camera.py`、`hand_tracking.py`、`stereo_depth.py`、`device_detector.py`、`d435_camera.py`、`egodata_writer.py`、`task_record.py`、`render_engine.py`、`helpers.py`(`as _settings`)等)、`ui/`(`main_window.py`、`camera_widget.py`、`device_panel.py`、`playback_dialog.py`、`upload_dialog.py`、`task_page.py`)以及 `tools/tests/` 多个用例 import。自身仅依赖 `os` 与 `config.__version__`。
collector/docs/core.md CHANGED
@@ -71,11 +71,13 @@
71
  | 名称 | 签名要点 | 作用 | 返回/副作用 |
72
  |---|---|---|---|
73
  | `CameraState` | 类常量 | 状态枚举 | `DISCONNECTED/IDLE/RECORDING/ERROR` |
74
- | `list_v4l_devices` | `(max_index=16)` | sysfs 只读枚举 V4L2 设备,by-id 分组、每物理设备只留主视频流 | 每项 `{video_index, name, serial, by_id_path, vid, pid, is_sdk, is_realsense}` |
75
  | `detect_cameras` | `(max_index=8)` | 打开测试(test_read)枚举可用相机 | `[(idx, backend), ...]` |
76
  | `_try_open_camera`(内部) | `(index, test_read=False, fallback_all_by_id=False)` | 按后端列表(Linux: V4L2/FFMPEG/ANY;Windows: DShow/MSMF/ANY)尝试打开 | `(VideoCapture, backend名)` 或 `(None, "")` |
77
  | `_is_sdk_device` / `_is_realsense_node`(内部) | `(index)` | sysfs name/VID:PID 判 FTDI SDK 设备与 RealSense 节点 | `bool` |
78
  | `_usb_vid_pid`(内部) | `(video_index)` | 沿 sysfs 设备树向上(≤6 层)找 `idVendor`/`idProduct` | `(vid, pid)` 小写十六进制或 `None` |
 
 
79
  | `CameraWorker` | `(camera_index=0, resolution=None, record_queue=None)` | UVC 采集 worker;默认分辨率 `settings.DEFAULT_RESOLUTION`(1280×960@30,MJPG) | 信号:`frame_ready(ndarray)`、`state_changed(str)`、`error_occurred(str)`、`fps_updated(float)`、`camera_opened(int,int,str)` |
80
  | `CameraWorker.start` / `stop` / `pause` / `resume` | 无参 | 生命周期控制(stop join ≤5s) | 无 |
81
  | `CameraWorker.state` / `is_connected` / `resolution` / `latest_capture_ts_us` | property | 状态、连接、实际分辨率、最新帧采集时间戳(Unix 微秒,线程安全) | 对应值 |
@@ -134,10 +136,13 @@ RGB(BGR 三通道)+ 深度(uint16,归一化毫米)两路信号。左
134
  | `detect_devices` | `(max_index=settings.DEVICE_SCAN_MAX_INDEX)` | 四段枚举(UVC+D435+S80M+BLE),各自容错整体不崩 | `List[DeviceInfo]` |
135
  | `DeviceScanner` | `(parent=None, max_index=None)` | 后台线程扫描,`_busy` 守卫防轮询堆积 | 信号 `scan_finished(list)`;`request_scan()`/`stop()` |
136
  | `set_ble_scan_suppressed` | `(on: bool)` | 抑制 bleak 主动发现(手套连接中防扫描挤占吞吐),不影响 bluetoothctl 列表 | 无 |
137
- | `_list_uvc_devices` / `_list_d435_devices` / `_list_s80m_devices` / `_list_ble_devices`(内部) | 各自 max_index | 分段枚举 | `List[DeviceInfo]` |
 
 
 
138
  | `_is_glove_name`(内部) | `(name)` | 广播名判手套:含 "matrix" 或单字母 `l/r/left/right/l_glove/r_glove/left_glove/right_glove` | `bool` |
139
 
140
- **关键数据**:`DeviceInfo.key` 格式约定:`"uvc:{by-id前缀或索引}"`、`"d435:{serial}"`、`"s80m:ftdi"`、`"ble:{MAC}"`;`kind` 取值 `uvc | d435 | s80m | data_ble | ble`(`data_ble`=手套)。BLE 常量 `BLE_DISCOVERY_INTERVAL_S=20.0`、`BLE_DISCOVERY_TIMEOUT_S=5.0`。S80M:FTDI 命中一次即返回单条(SDK 配置写死 video0/video2)。RealSense UVC 节点判定:vendor 8086 且 PID 0b07(D435)或驱动 name 含 "RealSense"(D435i 为 0b3a、其余型号 PID 各异,name 兜底)。
141
 
142
  **调用关系**:被 `ui/main_window.py`(`DeviceScanner`)、`ui/device_panel.py`、`tools/tests/device_panel_gui_smoke_test.py`(`DeviceInfo`/`detect_devices`)、`tools/tests/glove_widget_test.py`、`tools/tests/multi_device_registry_test.py` 引用;调用了 `core/camera.py`。
143
 
 
71
  | 名称 | 签名要点 | 作用 | 返回/副作用 |
72
  |---|---|---|---|
73
  | `CameraState` | 类常量 | 状态枚举 | `DISCONNECTED/IDLE/RECORDING/ERROR` |
74
+ | `list_v4l_devices` | `(max_index=16)` | sysfs 只读枚举 V4L2 设备,**按物理 USB 设备**分组(键取拓扑路径如 `1-5`,非 USB 节点退回 realpath)、每物理设备只留主视频流 | 每项 `{video_index, name, serial, by_id_path, by_id_ambiguous, usb_path, vid, pid, is_sdk, is_realsense}`;无对应链接时 `by_id_path` 为 `None`。`by_id_ambiguous=True` = 该前缀被同型号多台共用、链接归属会在重枚举时翻转,**不能当 key**(见 `_ambiguous_by_id_prefixes`);`by_id_path` 本身不受影响,仍是「真路径或 None」,照旧可用于打开设备 |
75
  | `detect_cameras` | `(max_index=8)` | 打开测试(test_read)枚举可用相机 | `[(idx, backend), ...]` |
76
  | `_try_open_camera`(内部) | `(index, test_read=False, fallback_all_by_id=False)` | 按后端列表(Linux: V4L2/FFMPEG/ANY;Windows: DShow/MSMF/ANY)尝试打开 | `(VideoCapture, backend名)` 或 `(None, "")` |
77
  | `_is_sdk_device` / `_is_realsense_node`(内部) | `(index)` | sysfs name/VID:PID 判 FTDI SDK 设备与 RealSense 节点 | `bool` |
78
  | `_usb_vid_pid`(内部) | `(video_index)` | 沿 sysfs 设备树向上(≤6 层)找 `idVendor`/`idProduct` | `(vid, pid)` 小写十六进制或 `None` |
79
+ | `_physical_usb_path` / `_usb_ident_strings`(内部) | `(index)` | 节点所属物理 USB 设备的拓扑路径 / 沿 sysfs 向上(≤6 层)找 USB `manufacturer`+`product` 字符串 | `str`(如 `"1-5"`)或 `None` / `(厂商, 型号)`,取不到给空串 |
80
+ | `_v4l_nodes_by_physical_device`(内部) | `(max_index)` | sysfs 里**所有** v4l 节点按物理设备分组——同型号同序列号两台并存时 udev 的 by-id 链接名会相撞,只有拓扑分得开 | `{物理设备键: [video_index, ...]}` |
81
  | `CameraWorker` | `(camera_index=0, resolution=None, record_queue=None)` | UVC 采集 worker;默认分辨率 `settings.DEFAULT_RESOLUTION`(1280×960@30,MJPG) | 信号:`frame_ready(ndarray)`、`state_changed(str)`、`error_occurred(str)`、`fps_updated(float)`、`camera_opened(int,int,str)` |
82
  | `CameraWorker.start` / `stop` / `pause` / `resume` | 无参 | 生命周期控制(stop join ≤5s) | 无 |
83
  | `CameraWorker.state` / `is_connected` / `resolution` / `latest_capture_ts_us` | property | 状态、连接、实际分辨率、最新帧采集时间戳(Unix 微秒,线程安全) | 对应值 |
 
136
  | `detect_devices` | `(max_index=settings.DEVICE_SCAN_MAX_INDEX)` | 四段枚举(UVC+D435+S80M+BLE),各自容错整体不崩 | `List[DeviceInfo]` |
137
  | `DeviceScanner` | `(parent=None, max_index=None)` | 后台线程扫描,`_busy` 守卫防轮询堆积 | 信号 `scan_finished(list)`;`request_scan()`/`stop()` |
138
  | `set_ble_scan_suppressed` | `(on: bool)` | 抑制 bleak 主动发现(手套连接中防扫描挤占吞吐),不影响 bluetoothctl 列表 | 无 |
139
+ | `_list_uvc_devices`(内部) | `(max_index, *, gripper_hubs=None)` | UVC 分段枚举;`gripper_hubs`=夹爪控制板占用的 USB 根端口集合,用来把单插的 DECXIN 与 rig 那颗同 VID/PID 的相机分开(`None`=未知,一律按组件排除) | `List[DeviceInfo]` |
140
+ | `_list_d435_devices` / `_list_s80m_devices` / `_list_ble_devices`(内部) | 各自 max_index | 分段枚举 | `List[DeviceInfo]` |
141
+ | `gripper_root_hubs` | `(gripper_devices)` | 从夹爪控制板的 tty 路径取它占用的 USB 根端口集合;空集=本次没接 rig | `set[str]` |
142
+ | `_is_gripper_component_camera`(内部) | `(d, *, gripper_hubs=None)` | 按 VID/PID 判夹爪组件相机;DECXIN 若在非控制板根端口下则放行(单插那颗,有独立用途),Sightac/FT602 始终排除;相机自身根端口取不到时保守排除 | `bool` |
143
  | `_is_glove_name`(内部) | `(name)` | 广播名判手套:含 "matrix" 或单字母 `l/r/left/right/l_glove/r_glove/left_glove/right_glove` | `bool` |
144
 
145
+ **关键数据**:`DeviceInfo.key` 格式约定:`"uvc:{by-id前缀 | usb-<拓扑路径> | 索引}"`(无链接、**或前缀被同型号多台共用**(`by_id_ambiguous`,链接归属会在重枚举时翻转)→ 退到拓扑路径,两者都跨重启稳定,索引兜底但会漂移)、`"d435:{serial}"`、`"s80m:ftdi"`、`"ble:{MAC}"`;`kind` 取值 `uvc | d435 | s80m | data_ble | ble`(`data_ble`=手套)。BLE 常量 `BLE_DISCOVERY_INTERVAL_S=20.0`、`BLE_DISCOVERY_TIMEOUT_S=5.0`。S80M:FTDI 命中一次即返回单条(SDK 配置写死 video0/video2)。RealSense UVC 节点判定:vendor 8086 且 PID 0b07(D435)或驱动 name 含 "RealSense"(D435i 为 0b3a、其余型号 PID 各异,name 兜底)。
146
 
147
  **调用关系**:被 `ui/main_window.py`(`DeviceScanner`)、`ui/device_panel.py`、`tools/tests/device_panel_gui_smoke_test.py`(`DeviceInfo`/`detect_devices`)、`tools/tests/glove_widget_test.py`、`tools/tests/multi_device_registry_test.py` 引用;调用了 `core/camera.py`。
148
 
collector/docs/data.md CHANGED
@@ -45,7 +45,7 @@
45
  }
46
  ```
47
 
48
- - **key**:设备稳定标识 `DeviceInfo.stable_key`,形式如 `uvc:{by-id 前缀}`、
49
  `d435:{serial}`、`ble:{MAC}` 等(含设备序列号/MAC,属本地敏感信息,不入库)。
50
  - **value**:规范形式为 `{"name": str, "sensor"?: str}`;旧版本可能是纯字符串,
51
  读取时自动兼容升级。
 
45
  }
46
  ```
47
 
48
+ - **key**:设备稳定标识 `DeviceInfo.stable_key`,形式如 `uvc:{by-id 前缀}`(同型号同序列号两台并存时 udev 的 by-id 链接名相撞、只有一台拿得到链接,**且谁拿到取决于注册顺序、重枚举时会翻转** ⇒ 两台都退到 `uvc:usb-<USB 拓扑路径>` 如 `uvc:usb-1-5`,同样跨重启稳定)、
49
  `d435:{serial}`、`ble:{MAC}` 等(含设备序列号/MAC,属本地敏感信息,不入库)。
50
  - **value**:规范形式为 `{"name": str, "sensor"?: str}`;旧版本可能是纯字符串,
51
  读取时自动兼容升级。
collector/tools/tests/test_device_detector.py CHANGED
@@ -100,6 +100,59 @@ def _main():
100
  check(infos[0].video_index == 2 and infos[0].serial == "2024010100",
101
  f"webcam 索引/序号正确: idx={infos[0].video_index} serial={infos[0].serial}")
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  print("── 3. _list_s80m_devices 多台按序列号区分(mock _ftdi_camera_groups) ──")
104
  fake_cams = [
105
  {"usb_path": "1-3.4.1", "serial": "000000000001", "stereo_index": 0},
@@ -174,6 +227,94 @@ def _main():
174
  "name": "Sightac"}),
175
  "VID/PID 缺失时 by-id 兜底排除")
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  # 夹爪枚举:mock 串口枚举 + 资源可用 → 一条 gripper 条目
178
  class _Port:
179
  def __init__(self, vid, pid, sn, dev):
 
100
  check(infos[0].video_index == 2 and infos[0].serial == "2024010100",
101
  f"webcam 索引/序号正确: idx={infos[0].video_index} serial={infos[0].serial}")
102
 
103
+ print("── 2b. list_v4l_devices 按物理设备分组(by-id 被顶掉也可见) ──")
104
+ # 两颗同型号同序列号 DECXIN:udev 的 by-id 链接名唯一(厂商_型号_序列号
105
+ # 完全相同),只有后注册那颗有链接,另一颗成孤儿。以 by-id 为枚举入口
106
+ # 会让孤儿**整个从面板消失**(真机:video8/9 曾是这种孤儿)。
107
+ nodes = {"1-5": [8, 9], "1-2.2.2": [10, 11]}
108
+ hub_of = {8: "1-5", 9: "1-5", 10: "1-2.2.2", 11: "1-2.2.2"}
109
+ with patch("core.camera._v4l_nodes_by_physical_device",
110
+ return_value=nodes), \
111
+ patch("core.camera._physical_usb_path",
112
+ side_effect=lambda i: hub_of.get(i)), \
113
+ patch("core.camera._find_persistent_v4l_path",
114
+ side_effect=lambda i: ("/dev/v4l/by-id/usb-DECXIN_DECXIN_CAMERA"
115
+ "_01.00.00-video-index0" if i == 10 else None)), \
116
+ patch("core.camera._usb_vid_pid", return_value=("1bcf", "2d4f")), \
117
+ patch("core.camera._usb_ident_strings",
118
+ return_value=("DECXIN", "DECXIN CAMERA")), \
119
+ patch("core.camera._is_sdk_device", return_value=False), \
120
+ patch("core.camera._is_realsense_node", return_value=False):
121
+ devs = cam.list_v4l_devices(16)
122
+ check(sorted(d["video_index"] for d in devs) == [8, 10],
123
+ f"两颗各取主视频流(不是聚成一颗): {[d['video_index'] for d in devs]}")
124
+ orphan = next(d for d in devs if d["video_index"] == 8)
125
+ check(orphan["by_id_path"] is None and orphan["usb_path"] == "1-5"
126
+ and orphan["name"] == "DECXIN DECXIN CAMERA",
127
+ f"孤儿靠 USB 拓扑+字符串兜底: {orphan}")
128
+ # 同型号 + 有一台拿不到链接 ⇒ 那个前缀不能当 key:**谁拿链接取决于注册
129
+ # 顺序、重新枚举时会翻转**,于是拿到链接的 video10 自己也得放弃 by-id
130
+ # 前缀。注意判据不能是「数前缀重复」—— 任一时刻只有一台有链接,另一台
131
+ # 压根进不了计数,这么写永远数不出来。
132
+ check(next(d for d in devs if d["video_index"] == 10)["by_id_ambiguous"] is True,
133
+ "同型号无链接设备在场 → 该 by-id 前缀标记有歧义(拿链接的也要退)")
134
+ check(orphan["by_id_ambiguous"] is False,
135
+ "孤儿无前缀,不参与歧义标记(by_id_path=None 本就退拓扑)")
136
+
137
+ # 对照:同型号**不同序列号**两台各有各的链接 → 前缀唯一,不许误判降级
138
+ link2 = {8: "/dev/v4l/by-id/usb-DECXIN_DECXIN_CAMERA_01.00.00-video-index0",
139
+ 10: "/dev/v4l/by-id/usb-DECXIN_DECXIN_CAMERA_01.00.01-video-index0"}
140
+ with patch("core.camera._v4l_nodes_by_physical_device", return_value=nodes), \
141
+ patch("core.camera._physical_usb_path",
142
+ side_effect=lambda i: hub_of.get(i)), \
143
+ patch("core.camera._find_persistent_v4l_path",
144
+ side_effect=lambda i: link2.get(i)), \
145
+ patch("core.camera._usb_vid_pid", return_value=("1bcf", "2d4f")), \
146
+ patch("core.camera._usb_ident_strings",
147
+ return_value=("DECXIN", "DECXIN CAMERA")), \
148
+ patch("core.camera._is_sdk_device", return_value=False), \
149
+ patch("core.camera._is_realsense_node", return_value=False):
150
+ devs2 = cam.list_v4l_devices(16)
151
+ check(all(d["by_id_ambiguous"] is False for d in devs2)
152
+ and len({d["by_id_path"] for d in devs2}) == 2,
153
+ f"同型号不同序列号各持前缀 → 不降级: "
154
+ f"{[(d['video_index'], d['by_id_ambiguous']) for d in devs2]}")
155
+
156
  print("── 3. _list_s80m_devices 多台按序列号区分(mock _ftdi_camera_groups) ──")
157
  fake_cams = [
158
  {"usb_path": "1-3.4.1", "serial": "000000000001", "stereo_index": 0},
 
227
  "name": "Sightac"}),
228
  "VID/PID 缺失时 by-id 兜底排除")
229
 
230
+ print("── 4c. 单插 DECXIN 按 USB 根端口与 rig 区分 ──")
231
+ # 真机:控制板 1-2.2.1、rig 的 DECXIN 1-2.2.2(同根端口 1-2),
232
+ # 单插的 DECXIN 1-5。同 VID/PID 只能靠拓扑分开。
233
+ decxin = {"vid": "1bcf", "pid": "2d4f", "video_index": 8}
234
+ with patch("core.device_detector._v4l_root_hub", return_value="1-5"):
235
+ check(not det._is_gripper_component_camera(decxin, gripper_hubs=set()),
236
+ "没接 rig → 单插 DECXIN 放行")
237
+ check(not det._is_gripper_component_camera(decxin,
238
+ gripper_hubs={"1-2"}),
239
+ "与控制板不同根端口 → 单插 DECXIN 放行")
240
+ with patch("core.device_detector._v4l_root_hub", return_value="1-2"):
241
+ check(not det._is_gripper_component_camera(decxin, gripper_hubs=set()),
242
+ "没接 rig → rig 位置的 DECXIN 也放行")
243
+ check(det._is_gripper_component_camera(decxin, gripper_hubs={"1-2"}),
244
+ "与控制板同根端口 → rig 的 DECXIN 排除")
245
+ with patch("core.device_detector._v4l_root_hub", return_value=None):
246
+ check(det._is_gripper_component_camera(decxin, gripper_hubs={"1-2"}),
247
+ "根端口取不到 → 保守排除,不与 rig 双开")
248
+ check(det._is_gripper_component_camera(decxin),
249
+ "不传 gripper_hubs(未知)→ 维持旧契约排除")
250
+ with patch("core.device_detector._v4l_root_hub", return_value="1-5"):
251
+ check(det._is_gripper_component_camera(
252
+ {"vid": "0c45", "pid": "636f", "video_index": 9},
253
+ gripper_hubs=set()), "Sightac 单插也无独立用途 → 始终排除")
254
+
255
+ # 端到端:两颗同型号 DECXIN 同时在位,只放行单插那颗
256
+ def _mk_decxin(idx, tail):
257
+ return {"video_index": idx, "name": "DECXIN DECXIN CAMERA 01.00.00",
258
+ "serial": "01.00.00",
259
+ "by_id_path": f"/dev/v4l/by-id/usb-DECXIN_DECXIN_CAMERA_{tail}"
260
+ f"-video-index0",
261
+ "vid": "1bcf", "pid": "2d4f",
262
+ "is_sdk": False, "is_realsense": False}
263
+ # hub_of 按 _v4l_root_hub 的契约给**根端口**(1-2.2.2 的根端口是 1-2)
264
+ hub_of = {8: "1-5", 10: "1-2"}
265
+ with patch("core.device_detector.list_v4l_devices",
266
+ return_value=[_mk_decxin(8, "01.00.00"), _mk_decxin(10, "01.00.01")]), \
267
+ patch("core.device_detector._v4l_root_hub",
268
+ side_effect=lambda i: hub_of.get(i)):
269
+ infos = _list_uvc_devices(16, gripper_hubs={"1-2"})
270
+ check([i.video_index for i in infos] == [8]
271
+ and infos[0].key == "uvc:usb-DECXIN_DECXIN_CAMERA_01.00.00",
272
+ f"只放行单插那颗且 key 与 device_names 稳定: "
273
+ f"{[(i.video_index, i.key) for i in infos]}")
274
+
275
+ # by-id 被顶掉时 key 退到 USB 拓扑路径(跨重启稳定),不是会漂移的索引
276
+ with patch("core.device_detector.list_v4l_devices", return_value=[
277
+ {"video_index": 8, "name": "DECXIN DECXIN CAMERA", "serial": "",
278
+ "by_id_path": None, "usb_path": "1-5",
279
+ "vid": "1bcf", "pid": "2d4f",
280
+ "is_sdk": False, "is_realsense": False}]), \
281
+ patch("core.device_detector._v4l_root_hub", return_value="1-5"):
282
+ infos = _list_uvc_devices(16, gripper_hubs={"1-2"})
283
+ check([i.key for i in infos] == ["uvc:usb-1-5"],
284
+ f"by-id 被顶掉 → key 退到 USB 拓扑路径: {[i.key for i in infos]}")
285
+
286
+ # by-id 前缀有歧义(被同型号两台共用,链接归属会在重枚举时翻转)→
287
+ # **拿到链接的那台也不能用**,否则同一台相机在 by-id key 与拓扑 key 之间
288
+ # 跳,面板表现为设备消失又出现、并把用户起的名字丢掉
289
+ with patch("core.device_detector.list_v4l_devices", return_value=[
290
+ {**_mk_decxin(8, "01.00.00"), "usb_path": "1-5",
291
+ "by_id_ambiguous": True},
292
+ {**_mk_decxin(10, "01.00.00"), "usb_path": "1-2.2.2",
293
+ "by_id_ambiguous": True}]), \
294
+ patch("core.device_detector._v4l_root_hub", return_value="1-5"):
295
+ infos = _list_uvc_devices(16, gripper_hubs={"1-2"})
296
+ keys = sorted(i.key for i in infos)
297
+ check(keys == ["uvc:usb-1-2.2.2", "uvc:usb-1-5"] and len(set(keys)) == 2,
298
+ f"前缀有歧义 → 两颗都退拓扑路径且 key 互不相同: {keys}")
299
+
300
+ # 歧义 + 连拓扑路径都取不到 → 退到索引(最后一档兜底,不许抛异常)
301
+ with patch("core.device_detector.list_v4l_devices", return_value=[
302
+ {**_mk_decxin(8, "01.00.00"), "usb_path": "",
303
+ "by_id_ambiguous": True}]), \
304
+ patch("core.device_detector._v4l_root_hub", return_value="1-5"):
305
+ infos = _list_uvc_devices(16, gripper_hubs={"1-2"})
306
+ check([i.key for i in infos] == ["uvc:8"],
307
+ f"歧义且无拓扑路径 → 退索引兜底: {[i.key for i in infos]}")
308
+
309
+ # gripper_root_hubs:从控制板 tty 路径取根端口
310
+ with patch("core.device_detector._tty_root_hub",
311
+ side_effect=lambda p: "1-2" if p == "/dev/ttyACM0" else None):
312
+ check(det.gripper_root_hubs(
313
+ [DeviceInfo(key="gripper:s", kind="gripper", display_name="g",
314
+ address="/dev/ttyACM0")]) == {"1-2"},
315
+ "控制板根端口取自 tty 路径")
316
+ check(det.gripper_root_hubs([]) == set(), "没接 rig → 空集")
317
+
318
  # 夹爪枚举:mock 串口枚举 + 资源可用 → 一条 gripper 条目
319
  class _Port:
320
  def __init__(self, vid, pid, sn, dev):
collector/ui/lite_window.py CHANGED
@@ -49,6 +49,7 @@ from core.device_detector import (DeviceInfo, _list_ble_devices,
49
  _list_gripper_devices,
50
  _list_usb_glove_devices,
51
  _list_uvc_devices,
 
52
  set_ble_scan_suppressed,
53
  usb_glove_prefer_side)
54
  from core.encoder_probe import list_working_ffmpegs
@@ -593,12 +594,14 @@ class LiteWindow(QMainWindow):
593
  d435 = _list_d435_devices()
594
  usb = _list_usb_glove_devices()
595
  ble = _list_ble_devices()
596
- uvc = _list_uvc_devices()
597
  # 夹爪:_list_gripper_devices 内部先查 paths.gripper_resources_available()
598
  # (native/ 未随包交付 → 空列表 → 组框照建、里面空着;Windows 上
599
  # core.gripper 因顶层 import fcntl 直接导入失败,同样被它内部
600
  # 的 except 兜成空列表)
601
  gripper = _list_gripper_devices()
 
 
 
602
  try:
603
  self._scan_result.emit(d435, usb, ble, uvc, gripper)
604
  except RuntimeError:
 
49
  _list_gripper_devices,
50
  _list_usb_glove_devices,
51
  _list_uvc_devices,
52
+ gripper_root_hubs,
53
  set_ble_scan_suppressed,
54
  usb_glove_prefer_side)
55
  from core.encoder_probe import list_working_ffmpegs
 
594
  d435 = _list_d435_devices()
595
  usb = _list_usb_glove_devices()
596
  ble = _list_ble_devices()
 
597
  # 夹爪:_list_gripper_devices 内部先查 paths.gripper_resources_available()
598
  # (native/ 未随包交付 → 空列表 → 组框照建、里面空着;Windows 上
599
  # core.gripper 因顶层 import fcntl 直接导入失败,同样被它内部
600
  # 的 except 兜成空列表)
601
  gripper = _list_gripper_devices()
602
+ # rig 的 DECXIN 与单插的同 VID/PID,按控制板根端口区分后再放行
603
+ # (见 _is_gripper_component_camera);先扫夹爪才拿得到根端口
604
+ uvc = _list_uvc_devices(gripper_hubs=gripper_root_hubs(gripper))
605
  try:
606
  self._scan_result.emit(d435, usb, ble, uvc, gripper)
607
  except RuntimeError: