Spaces:
Running
Running
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 +95 -1
- collector/config/__init__.py +17 -12
- collector/core/camera.py +131 -14
- collector/core/device_detector.py +112 -18
- collector/docs/config.md +1 -1
- collector/docs/core.md +8 -3
- collector/docs/data.md +1 -1
- collector/tools/tests/test_device_detector.py +141 -0
- collector/ui/lite_window.py +4 -1
collector/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
|
| 2 |
|
| 3 |
-

|
| 5 |

|
| 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 |
+

|
| 4 |

|
| 5 |

|
| 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.
|
| 7 |
|
| 8 |
-
# v1.3.
|
| 9 |
-
#
|
| 10 |
-
#
|
| 11 |
-
#
|
| 12 |
-
#
|
| 13 |
-
#
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
#
|
| 17 |
-
#
|
| 18 |
-
#
|
|
|
|
| 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 |
-
|
| 301 |
-
(
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
serial 启发式取前缀最后一段(仅纯字母数字且 ≥8 字符才认作序号)。
|
| 305 |
被占用的设备不会被打开,仍会稳定出现在列表中。
|
| 306 |
"""
|
| 307 |
devices = []
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
| 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
|
|
|
|
| 151 |
"""UVC 网络摄像头(排除 RealSense UVC 节点、FTDI SDK 设备与夹爪组件)。
|
| 152 |
|
| 153 |
夹爪组件的 Sightac/DECXIN/FTDI 相机由 libuvc 服务或 Fays 桥接独占,
|
| 154 |
-
绝不作为通用 UVC 相机出现
|
| 155 |
-
|
|
|
|
|
|
|
| 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 前缀(跨插拔稳定);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 561 |
vid = str(d.get("vid") or "").lower()
|
| 562 |
pid = str(d.get("pid") or "").lower()
|
| 563 |
-
if vid and pid:
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 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 设备,
|
| 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`
|
|
|
|
|
|
|
|
|
|
| 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前缀
|
| 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:
|