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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
14 changes: 14 additions & 0 deletions crates/can-motor-control-py/python/can_motor_control/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions crates/can-motor-control-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +77,10 @@ fn _native(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PySocketCanBus>()?;
#[cfg(target_os = "macos")]
m.add_class::<PyGsUsbBus>()?;
#[cfg(target_os = "macos")]
m.add_class::<PyGsUsbDeviceInfo>()?;
#[cfg(target_os = "macos")]
m.add_function(wrap_pyfunction!(py_list_gs_usb_devices, m)?)?;
m.add_class::<PyMotorSpec>()?;
m.add_class::<PyMotor>()?;
m.add_class::<PyArm>()?;
Expand Down
45 changes: 43 additions & 2 deletions crates/can-motor-control-py/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,
}

#[cfg(target_os = "macos")]
impl From<GsUsbDeviceInfo> 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<Vec<PyGsUsbDeviceInfo>> {
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")]
Expand Down
4 changes: 2 additions & 2 deletions crates/can-motor-control/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
38 changes: 38 additions & 0 deletions crates/can-motor-control/src/transport/gs_usb/macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

/// List macOS gs_usb adapters matching a USB vendor/product pair.
pub fn list_gs_usb_devices(
vendor_id: u16,
product_id: u16,
) -> Result<Vec<GsUsbDeviceInfo>, 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 {
Expand Down Expand Up @@ -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<String>) -> InitFailure {
InitFailure::Terminal(TransportError::InvalidConfiguration(message.into()))
}
Expand Down
2 changes: 1 addition & 1 deletion crates/can-motor-control/src/transport/gs_usb/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion crates/can-motor-control/src/transport/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
9 changes: 9 additions & 0 deletions docs/gs-usb-macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/python/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading