Skip to content

Commit 6693bdf

Browse files
committed
fix: correct USB vs direct-attach detection for Linux video devices
- Add _get_linux_video_connection_type() helper function - Check sysfs device path ancestry for '/usb' to detect USB devices - Fallback to checking subsystem symlink target - Non-USB devices (PCI, platform, virtual) correctly return 'direct' - Add 3 new tests for Linux video connection type detection Fixes: Built-in cameras and v4l2loopback devices were incorrectly labeled as 'usb' because the old logic checked for driver existence which is true for ALL v4l2 devices, not just USB ones.
1 parent 9424053 commit 6693bdf

File tree

2 files changed

+103
-6
lines changed

2 files changed

+103
-6
lines changed

marvain_cli/ops.py

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2488,6 +2488,52 @@ class DetectedDevice:
24882488
serial: str | None = None
24892489

24902490

2491+
def _get_linux_video_connection_type(video_path: str) -> str:
2492+
"""Determine if a Linux video device is USB or direct-attached.
2493+
2494+
Checks the sysfs device path ancestry to determine the bus type.
2495+
USB devices have '/usb' in their resolved sysfs path.
2496+
2497+
Args:
2498+
video_path: Path to the video device (e.g., /dev/video0)
2499+
2500+
Returns:
2501+
'usb' if the device is connected via USB, 'direct' otherwise
2502+
(includes PCI, platform, virtual devices like v4l2loopback)
2503+
"""
2504+
device_name = os.path.basename(video_path)
2505+
sysfs_device_path = f"/sys/class/video4linux/{device_name}/device"
2506+
2507+
try:
2508+
# Resolve the symlink to get the actual device path in sysfs
2509+
# USB devices will have paths like:
2510+
# /sys/devices/pci0000:00/.../usb1/1-2/1-2:1.0/video4linux/video0
2511+
# Built-in/PCI cameras will have paths like:
2512+
# /sys/devices/pci0000:00/.../0000:00:14.0/video4linux/video0
2513+
# Platform devices (e.g., Raspberry Pi camera):
2514+
# /sys/devices/platform/.../video4linux/video0
2515+
if os.path.exists(sysfs_device_path):
2516+
real_path = os.path.realpath(sysfs_device_path)
2517+
# Check if 'usb' appears in the path hierarchy
2518+
if "/usb" in real_path:
2519+
return "usb"
2520+
except OSError:
2521+
pass
2522+
2523+
# Fallback: check subsystem symlink
2524+
subsystem_path = f"/sys/class/video4linux/{device_name}/device/subsystem"
2525+
try:
2526+
if os.path.islink(subsystem_path):
2527+
subsystem = os.path.basename(os.path.realpath(subsystem_path))
2528+
if subsystem == "usb":
2529+
return "usb"
2530+
except OSError:
2531+
pass
2532+
2533+
# Default to direct for non-USB devices (PCI, platform, virtual, etc.)
2534+
return "direct"
2535+
2536+
24912537
def detect_local_devices() -> list[DetectedDevice]:
24922538
"""Detect USB and direct-attach devices on the local machine.
24932539
@@ -2568,10 +2614,8 @@ def _detect_video_devices() -> list[DetectedDevice]:
25682614
name = line.split(":", 1)[-1].strip()
25692615
break
25702616

2571-
# Check if USB device
2572-
conn_type = "usb" if "usb" in video_path.lower() or os.path.exists(
2573-
f"/sys/class/video4linux/{os.path.basename(video_path)}/device/driver"
2574-
) else "direct"
2617+
# Determine connection type by checking sysfs device path ancestry
2618+
conn_type = _get_linux_video_connection_type(video_path)
25752619

25762620
devices.append(
25772621
DetectedDevice(
@@ -2582,13 +2626,14 @@ def _detect_video_devices() -> list[DetectedDevice]:
25822626
)
25832627
)
25842628
except (subprocess.TimeoutExpired, FileNotFoundError):
2585-
# v4l2-ctl not installed, just add the device path
2629+
# v4l2-ctl not installed, try to determine connection type anyway
2630+
conn_type = _get_linux_video_connection_type(video_path)
25862631
devices.append(
25872632
DetectedDevice(
25882633
device_type="video",
25892634
name=video_path,
25902635
path=video_path,
2591-
connection_type="usb",
2636+
connection_type=conn_type,
25922637
)
25932638
)
25942639

tests/test_ops.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,58 @@ def test_detect_audio_devices_runs_without_error(self) -> None:
488488
devices = _detect_audio_devices()
489489
self.assertIsInstance(devices, list)
490490

491+
def test_get_linux_video_connection_type_returns_valid_type(self) -> None:
492+
"""_get_linux_video_connection_type should return 'usb' or 'direct'."""
493+
from marvain_cli.ops import _get_linux_video_connection_type
494+
495+
# Test with a non-existent device path (should return 'direct' as fallback)
496+
result = _get_linux_video_connection_type("/dev/video999")
497+
self.assertIn(result, ["usb", "direct"])
498+
499+
def test_get_linux_video_connection_type_usb_path_detection(self) -> None:
500+
"""_get_linux_video_connection_type should detect USB from sysfs path."""
501+
from marvain_cli.ops import _get_linux_video_connection_type
502+
import tempfile
503+
import os
504+
505+
# Create a mock sysfs structure with USB in the path
506+
with tempfile.TemporaryDirectory() as tmpdir:
507+
# Mock: /sys/class/video4linux/video0/device -> .../usb1/...
508+
video_dir = os.path.join(tmpdir, "sys", "class", "video4linux", "video0")
509+
usb_device_dir = os.path.join(tmpdir, "sys", "devices", "pci0000:00", "usb1", "1-2", "1-2:1.0")
510+
os.makedirs(video_dir, exist_ok=True)
511+
os.makedirs(usb_device_dir, exist_ok=True)
512+
513+
# Create symlink: video0/device -> usb device path
514+
device_link = os.path.join(video_dir, "device")
515+
os.symlink(usb_device_dir, device_link)
516+
517+
# Patch the sysfs base path for testing
518+
with mock.patch("marvain_cli.ops.os.path.exists") as mock_exists, \
519+
mock.patch("marvain_cli.ops.os.path.realpath") as mock_realpath:
520+
mock_exists.return_value = True
521+
# Simulate USB device path
522+
mock_realpath.return_value = "/sys/devices/pci0000:00/0000:00:14.0/usb1/1-2/1-2:1.0"
523+
524+
result = _get_linux_video_connection_type("/dev/video0")
525+
self.assertEqual(result, "usb")
526+
527+
def test_get_linux_video_connection_type_non_usb_path(self) -> None:
528+
"""_get_linux_video_connection_type should return 'direct' for non-USB devices."""
529+
from marvain_cli.ops import _get_linux_video_connection_type
530+
531+
# Patch to simulate a PCI device (no 'usb' in path)
532+
with mock.patch("marvain_cli.ops.os.path.exists") as mock_exists, \
533+
mock.patch("marvain_cli.ops.os.path.realpath") as mock_realpath, \
534+
mock.patch("marvain_cli.ops.os.path.islink") as mock_islink:
535+
mock_exists.return_value = True
536+
mock_islink.return_value = False
537+
# Simulate PCI device path (no 'usb' in path)
538+
mock_realpath.return_value = "/sys/devices/pci0000:00/0000:00:02.0/drm/card0"
539+
540+
result = _get_linux_video_connection_type("/dev/video0")
541+
self.assertEqual(result, "direct")
542+
491543

492544
if __name__ == "__main__":
493545
unittest.main()

0 commit comments

Comments
 (0)