Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions host/faultycmd-py/src/faultycmd/usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@

Cross-platform: uses ``pyserial.tools.list_ports`` so the same code
works on Linux (``/dev/ttyACM*``), Windows (``COM*``), and macOS
(``/dev/cu.usbmodem*``). Each port carries its VID/PID and either
a location string (Linux/macOS) or an ``MI_xx`` token in its hwid
(Windows) from which we recover the interface number inside the
composite.
(``/dev/cu.usbmodem*``). Recovering the interface number inside the
composite needs a different trick per platform:

- **Linux**: pyserial's ``location`` field ends in ``.<N>`` where
``N`` is the control-interface number.
- **Windows**: pyserial's ``hwid`` carries an ``MI_XX`` token.
- **macOS**: neither — pyserial's ``location`` is just the bus
address (e.g. ``20-2``), and the hwid has no ``MI_XX``. Two
fallbacks pick up the slack:
1. ``port.interface`` (the iInterface string set by the
firmware) matches one of ``"FaultyCat EMFI Control"``,
``"FaultyCat Crowbar Control"``, etc. when pyserial
populates that field on macOS.
2. The ``/dev/cu.usbmodem<...><N>`` device name itself
encodes the **data**-interface number as the trailing
digit — `usbmodem14201` → data IF 1 → control IF 0 (emfi),
`usbmodem14203` → control IF 2 (crowbar), and so on.

On Linux ``udevadm`` is kept as a fallback for systems where
pyserial's location parsing misses the interface number (older
Expand Down Expand Up @@ -78,17 +91,47 @@ class PortDiscoveryError(LookupError):
# Windows: "1-3:x.6" → 6 (yes, literal 'x' — config index is
# unknown on Windows so pyserial fills it
# with that placeholder)
# macOS: "<addr>.<n>" → n
# Older pyserial on macOS used to emit a similar ".N" suffix but newer
# versions (and current macOS) just give the bus topology — e.g.
# `20-2` — without any interface marker. The macOS-specific fallbacks
# below pick up that case.
_LOCATION_IFACE_RE = re.compile(r"\.(\d+)\s*$")

# pyserial may surface the firmware's iInterface descriptor string in
# ``port.interface``. Our firmware sets one per CDC (see
# ``usb/src/usb_descriptors.c``), so a direct lookup recovers the
# role even when the location/hwid don't carry the interface number.
# Values are the CONTROL-interface number (matches INTERFACE_NUMBERS).
_INTERFACE_BY_STRING: dict[str, int] = {
"FaultyCat EMFI Control": 0x00,
"FaultyCat Crowbar Control": 0x02,
"FaultyCat Scanner Shell": 0x04,
"FaultyCat Target UART": 0x06,
}

# macOS `usbmodem` device names look like `/dev/cu.usbmodem<...><N>`
# where the trailing digit `N` is the DATA interface number. For our
# 4-CDC composite the trailing digits are 1, 3, 5, 7 — control IFs
# 0, 2, 4, 6 (data = control + 1 per the CDC class spec). The middle
# `<...>` is a serial/bus hash that varies across boots, so we just
# anchor on `.usbmodem` and capture the very last digit. Single-digit
# is sufficient because our composite only exposes 8 interfaces total.
_MACOS_USBMODEM_RE = re.compile(r"\.usbmodem\d+?(\d)$")


def _interface_from_port(port) -> int | None:
"""Best-effort interface-number extraction from a ListPortInfo.

Tries (in order):
1. ``MI_XX`` in ``hwid`` (Windows convention).
2. Trailing ``.<n>`` in ``location`` (Linux/macOS pyserial).
2. Trailing ``.<n>`` in ``location`` (Linux + some pyserial
versions on macOS).
3. ``udevadm info`` for ``ID_USB_INTERFACE_NUM`` (Linux fallback).
4. iInterface string in ``port.interface`` matches one of the
firmware's CDC descriptors (macOS-friendly; works anywhere
pyserial populates the field).
5. Trailing digit of ``/dev/cu.usbmodem<...><N>`` device name
(macOS — `N` is the **data** interface, control = `N` - 1).
"""
hwid = port.hwid or ""
m = _WIN_MI_RE.search(hwid)
Expand All @@ -113,6 +156,19 @@ def _interface_from_port(port) -> int | None:
except (subprocess.CalledProcessError, OSError):
pass

iface_str = (getattr(port, "interface", None) or "").strip()
if iface_str in _INTERFACE_BY_STRING:
return _INTERFACE_BY_STRING[iface_str]

device = port.device or ""
m = _MACOS_USBMODEM_RE.search(device)
if m:
data_iface = int(m.group(1))
# Data interface = control + 1. Clamp at 0 so a hypothetical
# data-IF 0 (shouldn't happen — CDC always has the control
# pair) doesn't underflow.
return max(0, data_iface - 1)

return None


Expand Down
106 changes: 106 additions & 0 deletions host/faultycmd-py/tests/test_usb.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class FakePort:
pid: int | None
hwid: str = ""
location: str | None = None
interface: str | None = None


def _linux_port(dev: str, vid: int, pid: int, iface: int) -> FakePort:
Expand All @@ -48,6 +49,30 @@ def _windows_port(dev: str, vid: int, pid: int, iface: int) -> FakePort:
)


def _macos_port(
dev: str,
vid: int,
pid: int,
iface_string: str | None = None,
) -> FakePort:
"""Real macOS pyserial output (verified on a MacBook against
firmware vX.Y.Z.W): location is just the bus topology
(`20-2`), hwid has no MI_XX, and the interface number lives in
the trailing digit of the device name.

`iface_string` simulates pyserial populating `port.interface`
with the firmware's iInterface descriptor — None reproduces the
fallback path (parse the device name)."""
return FakePort(
device=dev,
vid=vid,
pid=pid,
hwid=(f"USB VID:PID={vid:04X}:{pid:04X} " "SER=FLT3-E6633C805B3A3827 LOCATION=20-2"),
location="20-2",
interface=iface_string,
)


@pytest.fixture
def linux_environment(monkeypatch):
"""ttyACM0..3 belong to FaultyCat at IF 0/2/4/6, ttyACM4 is FTDI."""
Expand All @@ -63,6 +88,42 @@ def linux_environment(monkeypatch):
return ports


@pytest.fixture
def macos_environment(monkeypatch):
"""The exact shape from the reported macOS bug: 4 FaultyCat CDCs
with device names `/dev/cu.usbmodem142{01,03,05,07}`, location
`20-2` (no `.<n>` suffix), and pyserial NOT populating
`port.interface`. The trailing digit on the device name is the
only signal available — this fixture exercises that fallback."""
ports = [
_macos_port("/dev/cu.usbmodem14201", 0x1209, 0xFA17),
_macos_port("/dev/cu.usbmodem14203", 0x1209, 0xFA17),
_macos_port("/dev/cu.usbmodem14205", 0x1209, 0xFA17),
_macos_port("/dev/cu.usbmodem14207", 0x1209, 0xFA17),
# An unrelated USB-modem on the same Mac, NOT FaultyCat.
_macos_port("/dev/cu.usbmodem14301", 0x2341, 0x0043),
]
monkeypatch.setattr("faultycmd.usb.list_ports.comports", lambda: ports)
monkeypatch.setattr("shutil.which", lambda _name: None)
return ports


@pytest.fixture
def macos_with_iinterface_environment(monkeypatch):
"""Variant of the macOS shape where pyserial DOES surface the
iInterface descriptor in `port.interface`. The string-match
fallback should fire before the device-name parse."""
ports = [
_macos_port("/dev/cu.usbmodem14201", 0x1209, 0xFA17, "FaultyCat EMFI Control"),
_macos_port("/dev/cu.usbmodem14203", 0x1209, 0xFA17, "FaultyCat Crowbar Control"),
_macos_port("/dev/cu.usbmodem14205", 0x1209, 0xFA17, "FaultyCat Scanner Shell"),
_macos_port("/dev/cu.usbmodem14207", 0x1209, 0xFA17, "FaultyCat Target UART"),
]
monkeypatch.setattr("faultycmd.usb.list_ports.comports", lambda: ports)
monkeypatch.setattr("shutil.which", lambda _name: None)
return ports


@pytest.fixture
def windows_environment(monkeypatch):
"""COM3..6 belong to FaultyCat at IF 0/2/4/6, COM7 is a USB-UART."""
Expand Down Expand Up @@ -129,6 +190,51 @@ def test_cdc_for_target_windows(windows_environment):
assert usb.cdc_for("target") == "COM6"


# -- macOS — device-name fallback (no port.interface populated) ------


def test_discover_macos_finds_only_faultycat(macos_environment):
ports = usb.discover()
assert len(ports) == 4
assert {p.interface for p in ports} == {0, 2, 4, 6}
# The Arduino-like usbmodem14301 must NOT come through.
assert all("14301" not in p.device for p in ports)


def test_cdc_for_emfi_macos(macos_environment):
# `usbmodem14201` has trailing digit 1 → data IF 1 → control IF 0.
assert usb.cdc_for("emfi") == "/dev/cu.usbmodem14201"


def test_cdc_for_crowbar_macos(macos_environment):
assert usb.cdc_for("crowbar") == "/dev/cu.usbmodem14203"


def test_cdc_for_scanner_macos(macos_environment):
assert usb.cdc_for("scanner") == "/dev/cu.usbmodem14205"


def test_cdc_for_target_macos(macos_environment):
assert usb.cdc_for("target") == "/dev/cu.usbmodem14207"


# -- macOS — iInterface-string fallback ------------------------------


def test_discover_macos_uses_iinterface_string(macos_with_iinterface_environment):
ports = usb.discover()
assert len(ports) == 4
assert {p.interface for p in ports} == {0, 2, 4, 6}


def test_cdc_for_emfi_macos_via_iinterface(macos_with_iinterface_environment):
assert usb.cdc_for("emfi") == "/dev/cu.usbmodem14201"


def test_cdc_for_target_macos_via_iinterface(macos_with_iinterface_environment):
assert usb.cdc_for("target") == "/dev/cu.usbmodem14207"


def test_cdc_for_unknown_role_raises(linux_environment):
with pytest.raises(ValueError):
usb.cdc_for("dap") # type: ignore[arg-type]
Expand Down
Loading