From 6c4d115135316fa4403f058bfaaeedb866e2007a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:35:23 +0000 Subject: [PATCH 1/7] Update gs_usb driver to support WinUSB by not forcing libusb1 backend Replace GsUsb.scan() and GsUsb.find() calls with local helper functions that call usb.core.find() without specifying a backend, allowing pyusb to auto-detect the best available backend. This enables WinUSB support on Windows in addition to libusbK. Update documentation to reflect WinUSB support and add unit tests. Co-authored-by: BenGardiner <243321+BenGardiner@users.noreply.github.com> --- can/interfaces/gs_usb.py | 42 ++++++++++++++-- doc/interfaces/gs_usb.rst | 6 ++- test/test_interface_gs_usb.py | 90 +++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 test/test_interface_gs_usb.py diff --git a/can/interfaces/gs_usb.py b/can/interfaces/gs_usb.py index 6297fc1f5..3b1dee606 100644 --- a/can/interfaces/gs_usb.py +++ b/can/interfaces/gs_usb.py @@ -12,6 +12,42 @@ logger = logging.getLogger(__name__) +def _scan_gs_usb_devices() -> list[GsUsb]: + """Scan for gs_usb devices using auto-detected backend. + + Unlike :meth:`GsUsb.scan`, this does not force the ``libusb1`` backend, + allowing ``pyusb`` to auto-detect the best available backend. This enables + support for WinUSB on Windows in addition to libusbK. + """ + return [ + GsUsb(dev) + for dev in ( + usb.core.find( + find_all=True, + custom_match=GsUsb.is_gs_usb_device, + ) + or [] + ) + ] + + +def _find_gs_usb_device(bus: int, address: int) -> GsUsb | None: + """Find a specific gs_usb device using auto-detected backend. + + Unlike :meth:`GsUsb.find`, this does not force the ``libusb1`` backend, + allowing ``pyusb`` to auto-detect the best available backend. This enables + support for WinUSB on Windows in addition to libusbK. + """ + dev = usb.core.find( + custom_match=GsUsb.is_gs_usb_device, + bus=bus, + address=address, + ) + if dev: + return GsUsb(dev) + return None + + class GsUsbBus(can.BusABC): def __init__( self, @@ -43,7 +79,7 @@ def __init__( self._index = None if index is not None: - devs = GsUsb.scan() + devs = _scan_gs_usb_devices() if len(devs) <= index: raise CanInitializationError( f"Cannot find device {index}. Devices found: {len(devs)}" @@ -51,7 +87,7 @@ def __init__( gs_usb = devs[index] self._index = index else: - gs_usb = GsUsb.find(bus=bus, address=address) + gs_usb = _find_gs_usb_device(bus=bus, address=address) if not gs_usb: raise CanInitializationError(f"Cannot find device {channel}") @@ -166,7 +202,7 @@ def shutdown(self): if self._index is not None: # Avoid errors on subsequent __init() by repeating the .scan() and .start() that would otherwise fail # the next time the device is opened in __init__() - devs = GsUsb.scan() + devs = _scan_gs_usb_devices() if self._index < len(devs): gs_usb = devs[self._index] try: diff --git a/doc/interfaces/gs_usb.rst b/doc/interfaces/gs_usb.rst index 8bab07c6f..580a994fc 100755 --- a/doc/interfaces/gs_usb.rst +++ b/doc/interfaces/gs_usb.rst @@ -52,8 +52,10 @@ Windows, Linux and Mac. The backend driver depends on `pyusb `_ so a ``pyusb`` backend driver library such as ``libusb`` must be installed. - On Windows a tool such as `Zadig `_ can be used to set the USB device driver to - ``libusbK``. + On Windows, WinUSB and libusbK are both supported. Devices with WCID (Windows Compatible ID) descriptors, + such as candleLight firmware, will automatically use WinUSB without any additional driver installation. + Alternatively, a tool such as `Zadig `_ can be used to set the USB device driver to + either ``WinUSB`` or ``libusbK``. Supplementary Info diff --git a/test/test_interface_gs_usb.py b/test/test_interface_gs_usb.py new file mode 100644 index 000000000..8aea1a28e --- /dev/null +++ b/test/test_interface_gs_usb.py @@ -0,0 +1,90 @@ +"""Tests for the gs_usb interface.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from can.interfaces.gs_usb import ( + GsUsbBus, + _find_gs_usb_device, + _scan_gs_usb_devices, +) + + +@patch("can.interfaces.gs_usb.usb.core.find") +def test_scan_does_not_force_backend(mock_find): + """Verify that _scan_gs_usb_devices does not pass a backend argument, + allowing pyusb to auto-detect the best available backend (WinUSB, libusbK, etc.).""" + mock_find.return_value = [] + + _scan_gs_usb_devices() + + mock_find.assert_called_once() + call_kwargs = mock_find.call_args[1] + assert "backend" not in call_kwargs, ( + "backend should not be specified so pyusb can auto-detect" + ) + assert call_kwargs["find_all"] is True + + +@patch("can.interfaces.gs_usb.usb.core.find") +def test_find_does_not_force_backend(mock_find): + """Verify that _find_gs_usb_device does not pass a backend argument, + allowing pyusb to auto-detect the best available backend (WinUSB, libusbK, etc.).""" + mock_find.return_value = None + + _find_gs_usb_device(bus=1, address=2) + + mock_find.assert_called_once() + call_kwargs = mock_find.call_args[1] + assert "backend" not in call_kwargs, ( + "backend should not be specified so pyusb can auto-detect" + ) + assert call_kwargs["bus"] == 1 + assert call_kwargs["address"] == 2 + + +@patch("can.interfaces.gs_usb.usb.core.find") +def test_scan_returns_gs_usb_devices(mock_find): + """Verify that _scan_gs_usb_devices wraps found USB devices in GsUsb objects.""" + mock_dev1 = MagicMock() + mock_dev2 = MagicMock() + mock_find.return_value = [mock_dev1, mock_dev2] + + devices = _scan_gs_usb_devices() + + assert len(devices) == 2 + assert devices[0].gs_usb is mock_dev1 + assert devices[1].gs_usb is mock_dev2 + + +@patch("can.interfaces.gs_usb.usb.core.find") +def test_find_returns_gs_usb_device(mock_find): + """Verify that _find_gs_usb_device wraps the found USB device in a GsUsb object.""" + mock_dev = MagicMock() + mock_find.return_value = mock_dev + + device = _find_gs_usb_device(bus=1, address=2) + + assert device is not None + assert device.gs_usb is mock_dev + + +@patch("can.interfaces.gs_usb.usb.core.find") +def test_find_returns_none_when_no_device(mock_find): + """Verify that _find_gs_usb_device returns None when no device is found.""" + mock_find.return_value = None + + device = _find_gs_usb_device(bus=1, address=2) + + assert device is None + + +@patch("can.interfaces.gs_usb.usb.core.find") +def test_scan_returns_empty_list_when_no_devices(mock_find): + """Verify that _scan_gs_usb_devices returns an empty list when no devices are found.""" + mock_find.return_value = [] + + devices = _scan_gs_usb_devices() + + assert devices == [] From 03d1b16c56374f0675f82b4a160ce3c46e8324c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:50:30 +0000 Subject: [PATCH 2/7] Add pyusb as explicit dependency in gs-usb optional group The gs_usb interface directly imports `usb` (pyusb) for USB device discovery, so pyusb must be an explicit dependency rather than relying on it being a transitive dependency of the gs-usb package. Co-authored-by: BenGardiner <243321+BenGardiner@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ddaf61ef5..e7568d1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ neovi = ["filelock", "python-ics>=2.12"] canalystii = ["canalystii>=0.1.0"] cantact = ["cantact>=0.0.7"] cvector = ["python-can-cvector"] -gs-usb = ["gs-usb>=0.2.1"] +gs-usb = ["gs-usb>=0.2.1", "pyusb>=1.0.2"] nixnet = ["nixnet>=0.3.2"] pcan = ["uptime~=3.0.1"] remote = ["python-can-remote"] From 713736fdce346df379e18de0a2979cc23bf6ee14 Mon Sep 17 00:00:00 2001 From: Ben Gardiner Date: Mon, 23 Feb 2026 09:59:05 -0500 Subject: [PATCH 3/7] gs_usb: treat timeout=None as forever pass '0' when timeout=None (as proposed by @zariiii9003 in https://github.com/hardbyte/python-can/pull/2026#issuecomment-3941747658) --- can/interfaces/gs_usb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/can/interfaces/gs_usb.py b/can/interfaces/gs_usb.py index 3b1dee606..31fd663f8 100644 --- a/can/interfaces/gs_usb.py +++ b/can/interfaces/gs_usb.py @@ -175,7 +175,7 @@ def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, boo frame = GsUsbFrame() # Do not set timeout as None or zero here to avoid blocking - timeout_ms = round(timeout * 1000) if timeout else 1 + timeout_ms = round(timeout * 1000) if timeout else 0 if not self.gs_usb.read(frame=frame, timeout_ms=timeout_ms): return None, False From 15a497845ca78e4ff02daddf68ece51793cc9d55 Mon Sep 17 00:00:00 2001 From: Ben Gardiner Date: Mon, 23 Feb 2026 10:18:28 -0500 Subject: [PATCH 4/7] add news fragment --- doc/changelog.d/2031.changed.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 doc/changelog.d/2031.changed.md diff --git a/doc/changelog.d/2031.changed.md b/doc/changelog.d/2031.changed.md new file mode 100644 index 000000000..7d33a6c80 --- /dev/null +++ b/doc/changelog.d/2031.changed.md @@ -0,0 +1,2 @@ +* make gs_usb use WinUSB instead of requiring libusbK. +* gs_usb: timeout=None means foreever From a0371b0df54d4e76d52c5cbcf4cd8d5cc46979ee Mon Sep 17 00:00:00 2001 From: Ben Gardiner Date: Mon, 23 Feb 2026 10:28:44 -0500 Subject: [PATCH 5/7] formatting fixes --- test/test_interface_gs_usb.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_interface_gs_usb.py b/test/test_interface_gs_usb.py index 8aea1a28e..b4ec76e5a 100644 --- a/test/test_interface_gs_usb.py +++ b/test/test_interface_gs_usb.py @@ -21,9 +21,9 @@ def test_scan_does_not_force_backend(mock_find): mock_find.assert_called_once() call_kwargs = mock_find.call_args[1] - assert "backend" not in call_kwargs, ( - "backend should not be specified so pyusb can auto-detect" - ) + assert ( + "backend" not in call_kwargs + ), "backend should not be specified so pyusb can auto-detect" assert call_kwargs["find_all"] is True @@ -37,9 +37,9 @@ def test_find_does_not_force_backend(mock_find): mock_find.assert_called_once() call_kwargs = mock_find.call_args[1] - assert "backend" not in call_kwargs, ( - "backend should not be specified so pyusb can auto-detect" - ) + assert ( + "backend" not in call_kwargs + ), "backend should not be specified so pyusb can auto-detect" assert call_kwargs["bus"] == 1 assert call_kwargs["address"] == 2 From e128a3d48ed59d8b3e45e84ed0a5e8f6cd4d2331 Mon Sep 17 00:00:00 2001 From: Ben Gardiner Date: Mon, 23 Feb 2026 10:32:44 -0500 Subject: [PATCH 6/7] gs_usb module needed in tests deps now --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e7568d1f9..1f4475176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,7 @@ test = [ "coverage==7.13.*", "hypothesis==6.*", "parameterized==0.9.*", + "gs-usb==0.3.*", ] dev = [ {include-group = "docs"}, From e06659046f078f9373bda5ef240d20426a4390cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:46:29 +0000 Subject: [PATCH 7/7] Fix gs_usb shutdown to always call parent BusABC.shutdown() BusABC has a class-level _is_shutdown = True attribute. When __init__ was not called (as in test mocks), GsUsbBus.shutdown() resolved this class attribute and returned early, never calling super().shutdown(). Restructure shutdown() to always call super().shutdown(), using the pre-call _is_shutdown state only to guard interface-specific cleanup. Co-authored-by: BenGardiner <243321+BenGardiner@users.noreply.github.com> --- can/interfaces/gs_usb.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/can/interfaces/gs_usb.py b/can/interfaces/gs_usb.py index 31fd663f8..35de63668 100644 --- a/can/interfaces/gs_usb.py +++ b/can/interfaces/gs_usb.py @@ -68,7 +68,6 @@ def __init__( :param can_filters: not supported :param bitrate: CAN network bandwidth (bits/s) """ - self._is_shutdown = False if (index is not None) and ((bus or address) is not None): raise CanInitializationError( "index and bus/address cannot be used simultaneously" @@ -194,10 +193,11 @@ def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, boo return msg, False def shutdown(self): - if self._is_shutdown: + already_shutdown = self._is_shutdown + super().shutdown() + if already_shutdown: return - super().shutdown() self.gs_usb.stop() if self._index is not None: # Avoid errors on subsequent __init() by repeating the .scan() and .start() that would otherwise fail @@ -211,4 +211,3 @@ def shutdown(self): gs_usb.stop() except usb.core.USBError: pass - self._is_shutdown = True