Skip to content
Draft
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies = [
"ipywidgets>=8.0.0,<9.0.0",
"ipykernel>=6.12.0", # implicitly required by ipywidgets >=8.0.5
"jsonschema>=4.9.0",
"libusb1>=3.3.1",
"matplotlib>=3.6.0",
"networkx>=3.1",
"numpy>=1.22.4",
Expand Down
143 changes: 87 additions & 56 deletions src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import os
from typing import TYPE_CHECKING

import usb1

Check failure on line 4 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (windows-latest, 3.12, false)

Stub file not found for "usb1" (reportMissingTypeStubs)

Check failure on line 4 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.12, false)

Stub file not found for "usb1" (reportMissingTypeStubs)

Check failure on line 4 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.13, false)

Stub file not found for "usb1" (reportMissingTypeStubs)

Check failure on line 4 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.14, false)

Stub file not found for "usb1" (reportMissingTypeStubs)
from libusb1 import libusb_error

Check failure on line 5 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (windows-latest, 3.12, false)

Stub file not found for "libusb1" (reportMissingTypeStubs)

Check failure on line 5 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.12, false)

Stub file not found for "libusb1" (reportMissingTypeStubs)

Check failure on line 5 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.13, false)

Stub file not found for "libusb1" (reportMissingTypeStubs)

Check failure on line 5 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.14, false)

Stub file not found for "libusb1" (reportMissingTypeStubs)
from usb1 import USBContext, USBDevice, USBDeviceHandle, USBError

Check failure on line 6 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (windows-latest, 3.12, false)

Stub file not found for "usb1" (reportMissingTypeStubs)

Check failure on line 6 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.12, false)

Stub file not found for "usb1" (reportMissingTypeStubs)

Check failure on line 6 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.13, false)

Stub file not found for "usb1" (reportMissingTypeStubs)

Check failure on line 6 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.14, false)

Stub file not found for "usb1" (reportMissingTypeStubs)
Comment on lines 1 to +6
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new imports include several unused symbols (os, usb1, libusb_error, USBDevice, USBError). Unused imports will fail linting and also make it harder to see which APIs are actually required. Please remove them (or use them if they are needed).

Suggested change
import os
from typing import TYPE_CHECKING
import usb1
from libusb1 import libusb_error
from usb1 import USBContext, USBDevice, USBDeviceHandle, USBError
from typing import TYPE_CHECKING
from usb1 import USBContext, USBDeviceHandle

Copilot uses AI. Check for mistakes.
Comment on lines +4 to +6
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usb1/libusb1 is imported at module import time, and this module is imported unconditionally from qcodes.instrument_drivers.Minicircuits.__init__. If libusb1 is unavailable (or fails to load due to missing system libusb), importing the Minicircuits driver package will fail even for users not using this instrument. Consider following the pattern used in USBHIDMixin.py: wrap the import in try/except and raise a clear error only when the driver is instantiated.

Copilot uses AI. Check for mistakes.

# QCoDeS imports
from qcodes.instrument_drivers.Minicircuits.Base_SPDT import (
MiniCircuitsSPDTBase,
Expand All @@ -12,37 +16,68 @@

from qcodes.instrument import InstrumentBaseKWArgs

try:
import clr # pyright: ignore[reportMissingTypeStubs,reportMissingImports]
except ImportError:
raise ImportError(
"""Module clr not found. Please obtain it by
installing QCoDeS with the
minicircuits_usb_spdt extra, e.g. by running
pip install qcodes[minicircuits_usb_spdt]"""
)
MINICIRCUITS_VENDOR_ID = 0x20CE
RF_SWITCH_PRODUCT_ID = 0x0022


def open_switch_with_sn(serial_number: str | None) -> USBDeviceHandle | None:
usb_context = USBContext()
if serial_number is not None:
device_iterator = usb_context.getDeviceIterator(
skip_on_error=True,
)
try:
for device in device_iterator:
if (
device.getVendorID() == MINICIRCUITS_VENDOR_ID
and device.getProductID() == RF_SWITCH_PRODUCT_ID
):
handle = device.open()
if get_serial_number(handle) == serial_number:
return handle
device.close() # Unsure what missing arguments are needed or what they do

Check failure on line 38 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (windows-latest, 3.12, false)

Arguments missing for parameters "device_p", "finalizer_dict", "descriptor_list", "libusb_unref_device", "libusb_free_config_descriptor" (reportCallIssue)

Check failure on line 38 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.12, false)

Arguments missing for parameters "device_p", "finalizer_dict", "descriptor_list", "libusb_unref_device", "libusb_free_config_descriptor" (reportCallIssue)

Check failure on line 38 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.13, false)

Arguments missing for parameters "device_p", "finalizer_dict", "descriptor_list", "libusb_unref_device", "libusb_free_config_descriptor" (reportCallIssue)

Check failure on line 38 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.14, false)

Arguments missing for parameters "device_p", "finalizer_dict", "descriptor_list", "libusb_unref_device", "libusb_free_config_descriptor" (reportCallIssue)
Comment on lines +36 to +38
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open_switch_with_sn opens a handle = device.open() but, on a serial-number mismatch, calls device.close() instead of closing the opened handle. This likely leaks the USBDeviceHandle and may not even be a valid API on USBDevice. Close the handle (and ensure it’s closed in all non-return paths) before continuing iteration.

Suggested change
if get_serial_number(handle) == serial_number:
return handle
device.close() # Unsure what missing arguments are needed or what they do
should_close_handle = True
try:
if get_serial_number(handle) == serial_number:
should_close_handle = False
return handle
finally:
if should_close_handle:
handle.close()

Copilot uses AI. Check for mistakes.
finally:
device_iterator.close()
else: # If no SN is provided, we can use the built-in function to return the first Minicircuits switch
return usb_context.openByVendorIDAndProductID(
MINICIRCUITS_VENDOR_ID, RF_SWITCH_PRODUCT_ID
)
return None
Comment on lines +41 to +45
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When serial_number is None, open_switch_with_sn returns the first matching device handle without any interface claim/reset/initialization, but later communication uses interruptWrite/interruptRead directly. This makes the connection path behave differently depending on whether a serial number is provided and is likely to fail unless the interface is claimed somewhere else. Please perform the required device setup (e.g., claim interface 0 / detach kernel driver if needed) after opening the handle in all cases, ideally in MiniCircuitsUsbSPDT.__init__.

Copilot uses AI. Check for mistakes.


def get_serial_number(handle: USBDeviceHandle) -> str:
handle.resetDevice()
handle.claimInterface(0)
cmd = [
41,
]
cmd_array = bytearray([0] * 64)
cmd_array[0 : len(cmd)] = cmd
handle.interruptWrite(endpoint=1, data=cmd_array, timeout=50)
response = handle.interruptRead(endpoint=1, length=64, timeout=1000)
Comment on lines +48 to +57
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_serial_number calls handle.claimInterface(0) but never releases the interface. This can leave the device in a claimed state (and/or fail subsequent claims) and is especially problematic if iterating over multiple devices. Wrap USB operations in try/finally and release the interface (and consider avoiding resetDevice() unless strictly required).

Copilot uses AI. Check for mistakes.
resp_length = response.index(bytearray([0]))
trimmed_response = response[1:resp_length]
return trimmed_response.decode("ascii")


class MiniCircuitsUsbSPDTSwitchChannel(
MiniCircuitsSPDTSwitchChannelBase["MiniCircuitsUsbSPDT"]
):
def _set_switch(self, switch: int) -> None:
self.parent.switch.Set_Switch(self.channel_letter, switch - 1)
self.parent._query_scpi(f"set{self.channel_letter}={switch - 1}")

def _get_switch(self) -> int:
status = self.parent.switch.GetSwitchesStatus(self._parent.address)[1]
return int(f"{status:04b}"[-1 - self.channel_number]) + 1
all_ports_state = int(self.parent._query_scpi("SWPORT?"))
bitmask = 2**self.channel_number
return int((all_ports_state & bitmask) >= 1) + 1


class MiniCircuitsUsbSPDT(MiniCircuitsSPDTBase):
CHANNEL_CLASS = MiniCircuitsUsbSPDTSwitchChannel
PATH_TO_DRIVER = r"mcl_RF_Switch_Controller64"
PATH_TO_DRIVER_45 = r"mcl_RF_Switch_Controller_NET45"

def __init__(
self,
name: str,
driver_path: str | None = None,
serial_number: str | None = None,
**kwargs: "Unpack[InstrumentBaseKWArgs]",
):
Comment on lines 78 to 83
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The driver_path parameter was removed from MiniCircuitsUsbSPDT.__init__. This is a breaking change for any downstream code that passed an explicit DLL path. If the goal is to switch transport implementations, consider keeping driver_path as an optional (deprecated) argument for backward compatibility, or document it clearly as removed in the changelog/newsfragment.

Copilot uses AI. Check for mistakes.
Expand All @@ -51,59 +86,55 @@

Args:
name: the name of the instrument
driver_path: path to the dll
serial_number: the serial number of the device
(printed on the sticker on the back side, without s/n)
kwargs: kwargs to be passed to Instrument class.

"""
# import .net exception so we can catch it below
# we keep this import local so that the module can be imported
# without a working .net install
clr.AddReference("System.IO")
from System.IO import ( # pyright: ignore[reportMissingImports] # noqa: PLC0415
FileNotFoundException,
)

super().__init__(name, **kwargs)
if os.name != "nt":
raise ImportError("""This driver only works in Windows.""")
try:
if driver_path is None:
try:
clr.AddReference(self.PATH_TO_DRIVER)
except FileNotFoundError:
clr.AddReference(self.PATH_TO_DRIVER_45)
else:
clr.AddReference(driver_path)

except (ImportError, FileNotFoundException):
raise ImportError(
"""Load of mcl_RF_Switch_Controller64.dll or mcl_RF_Switch_Controller_NET45.dll
not possible. Make sure the dll file is not blocked by Windows.
To unblock right-click the dll to open properties and check the 'unblock' checkmark
in the bottom. Check that your python installation is 64bit."""
)
try:
import mcl_RF_Switch_Controller64 as mw_driver # pyright: ignore[reportMissingImports]# noqa: PLC0415
except ImportError:
import mcl_RF_Switch_Controller_NET45 as mw_driver # pyright: ignore[reportMissingImports]# noqa: PLC0415

self.switch = mw_driver.USB_RF_SwitchBox()
self._handle: USBDeviceHandle | None = open_switch_with_sn(serial_number)

if not self.switch.Connect(serial_number):
raise RuntimeError("Could not connect to device")
self.address = self.switch.Get_Address()
self.serial_number = self.switch.Read_SN("")[1]
self.connect_message()
self.add_channels()
self.connect_message()

Comment on lines 94 to +99
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MiniCircuitsUsbSPDT.__init__ does not check whether open_switch_with_sn returned None (no device found / failed open). As written, initialization continues and later calls (e.g. add_channels() -> get_idn()) will raise a generic exception from handle. Please fail fast here with a clear RuntimeError/ConnectionError that includes the requested serial number.

Copilot uses AI. Check for mistakes.
@property
def handle(self) -> USBDeviceHandle:
if self._handle is None:
raise Exception # TODO Make better
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handle property raises a bare Exception with no message when _handle is None. This makes failures hard to diagnose and is inconsistent with the rest of the driver code (which typically raises RuntimeError with context). Please raise a specific exception with a useful message (e.g., indicating that the USB device failed to open).

Suggested change
raise Exception # TODO Make better
raise RuntimeError(
"USB device handle is not available; failed to open "
"the Mini-Circuits USB switch."
)

Copilot uses AI. Check for mistakes.
return self._handle

def _query_scpi(self, command: str) -> str:
cmd_bytes = bytearray([42, 58]) # Interrupt code for Send SCPI Command
cmd_bytes.extend(bytearray(command, "ascii"))
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_query_scpi pads commands to 64 bytes via bytearray(64 - len(cmd_bytes)) without validating length. If command is long enough, this will raise a low-level ValueError (negative bytearray length). Add an explicit length check and raise a clear error when the SCPI command cannot fit in a single 64-byte packet.

Suggested change
cmd_bytes.extend(bytearray(command, "ascii"))
cmd_bytes.extend(bytearray(command, "ascii"))
if len(cmd_bytes) > 64:
raise ValueError(
"SCPI command is too long to fit in a single 64-byte USB packet. "
f"Maximum command length is 62 ASCII bytes, got {len(cmd_bytes) - 2}."
)

Copilot uses AI. Check for mistakes.
cmd_bytes.extend(bytearray(64 - len(cmd_bytes)))

self.handle.interruptWrite(endpoint=1, data=cmd_bytes, timeout=50)

response = self.handle.interruptRead(endpoint=1, length=64, timeout=1000)
resp_length = response.index(bytearray([0]))
trimmed_response = response[1:resp_length]

return trimmed_response.decode("ascii")
Comment on lines +111 to +117
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_query_scpi assumes responses always contain a NUL terminator and uses response.index(bytearray([0])). If the terminator is missing, this will raise and surface as an unhelpful exception. Please handle missing terminators (and USB errors) explicitly and raise a driver-level error that includes the command that failed.

Suggested change
self.handle.interruptWrite(endpoint=1, data=cmd_bytes, timeout=50)
response = self.handle.interruptRead(endpoint=1, length=64, timeout=1000)
resp_length = response.index(bytearray([0]))
trimmed_response = response[1:resp_length]
return trimmed_response.decode("ascii")
try:
self.handle.interruptWrite(endpoint=1, data=cmd_bytes, timeout=50)
response = self.handle.interruptRead(endpoint=1, length=64, timeout=1000)
except USBError as exc:
raise RuntimeError(
f"SCPI command {command!r} failed during USB communication."
) from exc
resp_length = response.find(b"\x00")
if resp_length == -1:
raise RuntimeError(
f"SCPI command {command!r} failed: response was not NUL-terminated."
)
trimmed_response = response[1:resp_length]
try:
return trimmed_response.decode("ascii")
except UnicodeDecodeError as exc:
raise RuntimeError(
f"SCPI command {command!r} failed: response could not be decoded as ASCII."
) from exc

Copilot uses AI. Check for mistakes.

def get_idn(self) -> dict[str, str | None]:
# the arguments in those functions is the serial number or none if
# there is only one switch.
fw = self.switch.GetFirmware()
MN = self.switch.Read_ModelName("")[1]
SN = self.switch.Read_SN("")[1]
fw = self._query_scpi("FIRMWARE?")
MN = self._query_scpi("MN?")
SN = self._query_scpi("SN?")

id_dict = {"firmware": fw, "model": MN, "serial": SN, "vendor": "Mini-Circuits"}
return id_dict

Check failure on line 127 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (windows-latest, 3.12, false)

Type "dict[str, str]" is not assignable to return type "dict[str, str | None]"   "dict[str, str]" is not assignable to "dict[str, str | None]"     Type parameter "_VT@dict" is invariant, but "str" is not the same as "str | None"     Consider switching from "dict" to "Mapping" which is covariant in the value type (reportReturnType)

Check failure on line 127 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.12, false)

Type "dict[str, str]" is not assignable to return type "dict[str, str | None]"   "dict[str, str]" is not assignable to "dict[str, str | None]"     Type parameter "_VT@dict" is invariant, but "str" is not the same as "str | None"     Consider switching from "dict" to "Mapping" which is covariant in the value type (reportReturnType)

Check failure on line 127 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.13, false)

Type "dict[str, str]" is not assignable to return type "dict[str, str | None]"   "dict[str, str]" is not assignable to "dict[str, str | None]"     Type parameter "_VT@dict" is invariant, but "str" is not the same as "str | None"     Consider switching from "dict" to "Mapping" which is covariant in the value type (reportReturnType)

Check failure on line 127 in src/qcodes/instrument_drivers/Minicircuits/_minicircuits_usb_spdt.py

View workflow job for this annotation

GitHub Actions / pytestmypy (ubuntu-latest, 3.14, false)

Type "dict[str, str]" is not assignable to return type "dict[str, str | None]"   "dict[str, str]" is not assignable to "dict[str, str | None]"     Type parameter "_VT@dict" is invariant, but "str" is not the same as "str | None"     Consider switching from "dict" to "Mapping" which is covariant in the value type (reportReturnType)
Comment on lines 119 to 127
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_idn returns the raw responses from MN? and SN?. The existing ethernet driver strips the MN=/SN= prefixes (see _minicircuits_rc_spdt.py), and MiniCircuitsSPDTBase.get_number_of_channels() expects model to start with RC or USB and contain a dash-separated channel count. If the USB device returns the same prefixed format, channel detection will break. Please normalize MN/SN to match the existing driver output (e.g., strip MN=/SN= and whitespace).

Copilot uses AI. Check for mistakes.


# Notes:
# https://www.minicircuits.com/softwaredownload/Prog_Manual-2-Switch.pdf
# Section 3 of the Minicircuits Programming Manual for RF switches includes additional
# SCPI commands which we may find useful. These are not currently implemented
# For example: `SETP=[states]` allows multiple switch states to be set with a single command
#
# We may also eventually be able to unify the Ethernet interface used by the RC-SPDT and RC-SP4T drivers
# with the USB interface and have both rely on the SCPI commands
#
# Finally, the commands for SP4T and SPDT are not so different that we couldn't have a generic driver
# That works for all versions
Loading