From 7440da5fa3c776de51d9080487048e9a7e51e4f3 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 15:06:35 -0700 Subject: [PATCH] feat: expose macOS gs_usb device discovery --- .../python/can_motor_control/__init__.py | 4 +- .../python/can_motor_control/__init__.pyi | 14 ++++++ crates/can-motor-control-py/src/lib.rs | 8 +++- crates/can-motor-control-py/src/transport.rs | 45 ++++++++++++++++++- crates/can-motor-control/src/lib.rs | 4 +- .../src/transport/gs_usb/macos.rs | 38 ++++++++++++++++ .../src/transport/gs_usb/mod.rs | 2 +- crates/can-motor-control/src/transport/mod.rs | 2 +- docs/gs-usb-macos.md | 9 ++++ tests/python/test_smoke.py | 10 +++++ 10 files changed, 126 insertions(+), 10 deletions(-) diff --git a/crates/can-motor-control-py/python/can_motor_control/__init__.py b/crates/can-motor-control-py/python/can_motor_control/__init__.py index 65c3699..ad3d4ac 100644 --- a/crates/can-motor-control-py/python/can_motor_control/__init__.py +++ b/crates/can-motor-control-py/python/can_motor_control/__init__.py @@ -22,7 +22,7 @@ if sys.platform == "linux": from can_motor_control._native import SocketCanBus elif sys.platform == "darwin": - from can_motor_control._native import GsUsbBus + from can_motor_control._native import GsUsbBus, GsUsbDeviceInfo, list_gs_usb_devices try: from can_motor_control._native import MockFeedbackCodec @@ -52,7 +52,7 @@ if sys.platform == "linux": __all__.append("SocketCanBus") elif sys.platform == "darwin": - __all__.append("GsUsbBus") + __all__.extend(["GsUsbBus", "GsUsbDeviceInfo", "list_gs_usb_devices"]) if MockFeedbackCodec is not None: __all__.append("MockFeedbackCodec") diff --git a/crates/can-motor-control-py/python/can_motor_control/__init__.pyi b/crates/can-motor-control-py/python/can_motor_control/__init__.pyi index 7ae1d79..8ac56ae 100644 --- a/crates/can-motor-control-py/python/can_motor_control/__init__.pyi +++ b/crates/can-motor-control-py/python/can_motor_control/__init__.pyi @@ -35,6 +35,20 @@ if sys.platform == "linux": class SocketCanBus: def __init__(self, interface: str, fd: bool = False) -> None: ... elif sys.platform == "darwin": + class GsUsbDeviceInfo: + @property + def index(self) -> int: ... + @property + def vendor_id(self) -> int: ... + @property + def product_id(self) -> int: ... + @property + def serial_number(self) -> str | None: ... + + def list_gs_usb_devices( + *, vendor_id: int, product_id: int + ) -> list[GsUsbDeviceInfo]: ... + class GsUsbBus: def __init__( self, diff --git a/crates/can-motor-control-py/src/lib.rs b/crates/can-motor-control-py/src/lib.rs index eb1c2c5..4a0cdd4 100644 --- a/crates/can-motor-control-py/src/lib.rs +++ b/crates/can-motor-control-py/src/lib.rs @@ -23,11 +23,11 @@ use codec::{PyDamiaoCodec, PyMockFeedbackCodec, PyMotorType}; use frame::PyCanFrame; use robot::{PyArm, PyGripper, PyMotor, PyMotorGroup, PyRobot, PyRobotBuilder}; use spec::PyMotorSpec; -#[cfg(target_os = "macos")] -use transport::PyGsUsbBus; use transport::PyMockCanBus; #[cfg(target_os = "linux")] use transport::PySocketCanBus; +#[cfg(target_os = "macos")] +use transport::{py_list_gs_usb_devices, PyGsUsbBus, PyGsUsbDeviceInfo}; // The module argument sets each exception's `__module__`. Use `can_motor_control` // (the public package the exceptions are re-exported from and imported as), not @@ -77,6 +77,10 @@ fn _native(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; #[cfg(target_os = "macos")] m.add_class::()?; + #[cfg(target_os = "macos")] + m.add_class::()?; + #[cfg(target_os = "macos")] + m.add_function(wrap_pyfunction!(py_list_gs_usb_devices, m)?)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/crates/can-motor-control-py/src/transport.rs b/crates/can-motor-control-py/src/transport.rs index 0f13532..ba1023c 100644 --- a/crates/can-motor-control-py/src/transport.rs +++ b/crates/can-motor-control-py/src/transport.rs @@ -4,9 +4,11 @@ use std::sync::{Arc, Mutex}; #[cfg(target_os = "linux")] use can_motor_control::SocketCanBus; -use can_motor_control::{CanBus, MockCanBus}; #[cfg(target_os = "macos")] -use can_motor_control::{GsUsbBus, GsUsbConfig, GsUsbStatistics}; +use can_motor_control::{ + list_gs_usb_devices, GsUsbBus, GsUsbConfig, GsUsbDeviceInfo, GsUsbStatistics, +}; +use can_motor_control::{CanBus, MockCanBus}; use pyo3::prelude::*; use crate::errors::transport_to_pyerr; @@ -98,6 +100,45 @@ impl PySocketCanBus { } } +/// A discovered macOS gs_usb adapter. +#[cfg(target_os = "macos")] +#[pyclass(name = "GsUsbDeviceInfo", module = "can_motor_control", frozen)] +pub struct PyGsUsbDeviceInfo { + #[pyo3(get)] + pub index: usize, + #[pyo3(get)] + pub vendor_id: u16, + #[pyo3(get)] + pub product_id: u16, + #[pyo3(get)] + pub serial_number: Option, +} + +#[cfg(target_os = "macos")] +impl From for PyGsUsbDeviceInfo { + fn from(device: GsUsbDeviceInfo) -> Self { + Self { + index: device.index, + vendor_id: device.vendor_id, + product_id: device.product_id, + serial_number: device.serial_number, + } + } +} + +/// List macOS gs_usb adapters matching a USB vendor/product pair. +#[cfg(target_os = "macos")] +#[pyfunction(name = "list_gs_usb_devices", signature = (*, vendor_id, product_id))] +pub fn py_list_gs_usb_devices( + py: Python<'_>, + vendor_id: u16, + product_id: u16, +) -> PyResult> { + py.allow_threads(|| list_gs_usb_devices(vendor_id, product_id)) + .map(|devices| devices.into_iter().map(Into::into).collect()) + .map_err(transport_to_pyerr) +} + /// A classical-CAN bus backed by a gs_usb adapter through native macOS IOKit. #[cfg(target_os = "macos")] #[pyclass(name = "GsUsbBus", module = "can_motor_control")] diff --git a/crates/can-motor-control/src/lib.rs b/crates/can-motor-control/src/lib.rs index 7680ce6..44029a4 100644 --- a/crates/can-motor-control/src/lib.rs +++ b/crates/can-motor-control/src/lib.rs @@ -29,9 +29,9 @@ pub use robot::{Robot, RobotBuilder}; pub use spec::{GripperOpeningSpec, GroupSpecKind, MotorSpec, OpeningDirection}; #[cfg(target_os = "linux")] pub use transport::SocketCanBus; -pub use transport::{BusPoller, CanBus, MockCanBus, MockRecordedCall, TransportError}; #[cfg(target_os = "macos")] -pub use transport::{GsUsbBus, GsUsbConfig, GsUsbStatistics}; +pub use transport::{list_gs_usb_devices, GsUsbBus, GsUsbConfig, GsUsbDeviceInfo, GsUsbStatistics}; +pub use transport::{BusPoller, CanBus, MockCanBus, MockRecordedCall, TransportError}; // Re-export can-motor-codec public types so users have a single import surface. pub use motor_codec::{ diff --git a/crates/can-motor-control/src/transport/gs_usb/macos.rs b/crates/can-motor-control/src/transport/gs_usb/macos.rs index 57e504c..21ea739 100644 --- a/crates/can-motor-control/src/transport/gs_usb/macos.rs +++ b/crates/can-motor-control/src/transport/gs_usb/macos.rs @@ -46,6 +46,37 @@ const BREQ_DEVICE_CONFIG: u8 = 5; const MODE_RESET: u32 = 0; const MODE_START: u32 = 1; +/// One macOS gs_usb adapter returned by [`list_gs_usb_devices`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GsUsbDeviceInfo { + /// Enumeration index accepted by [`GsUsbConfig::index`]. + pub index: usize, + /// USB vendor identifier. + pub vendor_id: u16, + /// USB product identifier. + pub product_id: u16, + /// Stable USB serial number, when the adapter firmware provides one. + pub serial_number: Option, +} + +/// List macOS gs_usb adapters matching a USB vendor/product pair. +pub fn list_gs_usb_devices( + vendor_id: u16, + product_id: u16, +) -> Result, TransportError> { + let devices = nusb::list_devices().wait().map_err(discovery_error)?; + Ok(devices + .filter(|device| device.vendor_id() == vendor_id && device.product_id() == product_id) + .enumerate() + .map(|(index, device)| GsUsbDeviceInfo { + index, + vendor_id: device.vendor_id(), + product_id: device.product_id(), + serial_number: device.serial_number().map(str::to_owned), + }) + .collect()) +} + /// Configuration for opening one macOS gs_usb adapter. #[derive(Clone, Debug)] pub struct GsUsbConfig { @@ -743,6 +774,13 @@ fn classify_nusb_error(error: nusb::Error) -> InitFailure { } } +fn discovery_error(error: nusb::Error) -> TransportError { + match error.kind() { + ErrorKind::PermissionDenied => TransportError::PermissionDenied, + _ => TransportError::Io(io::Error::from(error)), + } +} + fn terminal_config(message: impl Into) -> InitFailure { InitFailure::Terminal(TransportError::InvalidConfiguration(message.into())) } diff --git a/crates/can-motor-control/src/transport/gs_usb/mod.rs b/crates/can-motor-control/src/transport/gs_usb/mod.rs index 5703830..04395f1 100644 --- a/crates/can-motor-control/src/transport/gs_usb/mod.rs +++ b/crates/can-motor-control/src/transport/gs_usb/mod.rs @@ -9,7 +9,7 @@ mod worker_core; mod macos; #[cfg(target_os = "macos")] -pub use macos::{GsUsbBus, GsUsbConfig}; +pub use macos::{list_gs_usb_devices, GsUsbBus, GsUsbConfig, GsUsbDeviceInfo}; #[cfg(target_os = "macos")] pub use worker_core::GsUsbStatistics; diff --git a/crates/can-motor-control/src/transport/mod.rs b/crates/can-motor-control/src/transport/mod.rs index 5e4b7ae..11f1595 100644 --- a/crates/can-motor-control/src/transport/mod.rs +++ b/crates/can-motor-control/src/transport/mod.rs @@ -15,7 +15,7 @@ mod socketcan; #[cfg(target_os = "macos")] pub use gs_usb::GsUsbStatistics; #[cfg(target_os = "macos")] -pub use gs_usb::{GsUsbBus, GsUsbConfig}; +pub use gs_usb::{list_gs_usb_devices, GsUsbBus, GsUsbConfig, GsUsbDeviceInfo}; pub use mock::{MockCanBus, MockRecordedCall}; pub use poller::BusPoller; #[cfg(target_os = "linux")] diff --git a/docs/gs-usb-macos.md b/docs/gs-usb-macos.md index 235ae98..6f7aee7 100644 --- a/docs/gs-usb-macos.md +++ b/docs/gs-usb-macos.md @@ -36,6 +36,15 @@ let bus = GsUsbBus::open(config)?; # Ok::<(), can_motor_control::TransportError>(()) ``` +List matching adapters before opening one: + +```python +from can_motor_control import list_gs_usb_devices + +for device in list_gs_usb_devices(vendor_id=0x1D50, product_id=0x606F): + print(device.index, device.serial_number) +``` + Choose either `serial_number` or `index`. If you omit both, the transport uses index zero. USB enumeration order can change after reconnects and reboots, so use a serial number for a fixed deployment. Duplicate serial numbers fail as diff --git a/tests/python/test_smoke.py b/tests/python/test_smoke.py index c6aae28..ff1bd9c 100644 --- a/tests/python/test_smoke.py +++ b/tests/python/test_smoke.py @@ -62,6 +62,8 @@ def test_imports(): assert not hasattr(can_motor_control, "GsUsbBus") elif sys.platform == "darwin": assert can_motor_control.GsUsbBus is not None + assert can_motor_control.GsUsbDeviceInfo is not None + assert callable(can_motor_control.list_gs_usb_devices) assert not hasattr(can_motor_control, "SocketCanBus") for counter in ("rx_received", "rx_dropped", "tx_accepted", "tx_completed"): assert hasattr(can_motor_control.GsUsbBus, counter) @@ -83,6 +85,14 @@ def test_macos_gs_usb_constructor_rejects_contradictory_selector_without_hardwar ) +def test_macos_gs_usb_device_listing_without_matching_hardware(): + if sys.platform != "darwin": + return + assert ( + can_motor_control.list_gs_usb_devices(vendor_id=0xFFFF, product_id=0xFFFF) == [] + ) + + def test_macos_gs_usb_readiness_wait_releases_gil(): if sys.platform != "darwin": return