From 244121359d120385762a4ac7f07d041648e21662 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 23 Feb 2026 11:49:53 +1100 Subject: [PATCH 01/27] BaseException should be reserved for built-in python code per https://docs.python.org/3.13/library/exceptions.html#BaseException --- src/fixate/drivers/ftdi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fixate/drivers/ftdi.py b/src/fixate/drivers/ftdi.py index 3b737641..dd136101 100644 --- a/src/fixate/drivers/ftdi.py +++ b/src/fixate/drivers/ftdi.py @@ -44,7 +44,7 @@ } -class FTD2XXError(BaseException): +class FTD2XXError(Exception): pass From d10786f578f92ce379aabd7403849847e4f6dcf3 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 3 Mar 2026 10:24:48 +1100 Subject: [PATCH 02/27] restructure ftdi library --- src/fixate/drivers/{ftdi.py => ftdi/__init__.py} | 0 src/fixate/drivers/{ => ftdi}/_ftdi.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/fixate/drivers/{ftdi.py => ftdi/__init__.py} (100%) rename src/fixate/drivers/{ => ftdi}/_ftdi.py (100%) diff --git a/src/fixate/drivers/ftdi.py b/src/fixate/drivers/ftdi/__init__.py similarity index 100% rename from src/fixate/drivers/ftdi.py rename to src/fixate/drivers/ftdi/__init__.py diff --git a/src/fixate/drivers/_ftdi.py b/src/fixate/drivers/ftdi/_ftdi.py similarity index 100% rename from src/fixate/drivers/_ftdi.py rename to src/fixate/drivers/ftdi/_ftdi.py From 1ca32bc8fb4b249cbc6edb1dd3d279309efbdb92 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 3 Mar 2026 11:05:52 +1100 Subject: [PATCH 03/27] update import references after restructure to ftdi module --- docs/conf.py | 2 +- src/fixate/drivers/ftdi/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 5d9a5651..34215746 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,7 +55,7 @@ # All aren't necessary for the docs themselves autodoc_mock_imports = [ # import a DLL/shared lib and is platform-dependent - "fixate.drivers._ftdi", + "fixate.drivers.ftdi._ftdi", "PyDAQmx", # pulls in platform-dependent libraries "pynput", diff --git a/src/fixate/drivers/ftdi/__init__.py b/src/fixate/drivers/ftdi/__init__.py index 8aa2ff3f..e3653553 100644 --- a/src/fixate/drivers/ftdi/__init__.py +++ b/src/fixate/drivers/ftdi/__init__.py @@ -8,7 +8,7 @@ from fixate.core.common import bits from fixate.core.exceptions import FixateError, InstrumentNotConnected -from fixate.drivers._ftdi import ftdI2xx +from fixate.drivers.ftdi._ftdi import ftdI2xx # Definitions UCHAR = ctypes.c_ubyte From 6569c3e03790c52ecc5ab0e6152a0249e5a68c12 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 3 Mar 2026 11:17:41 +1100 Subject: [PATCH 04/27] add dll and necessary bits to be able to include it in the fixate package and load it --- setup.cfg | 4 ++++ src/fixate/drivers/ftdi/_libmpsse.py | 26 ++++++++++++++++++++++ src/fixate/drivers/ftdi/libs/libmpsse.dll | Bin 0 -> 161792 bytes 3 files changed, 30 insertions(+) create mode 100644 src/fixate/drivers/ftdi/_libmpsse.py create mode 100644 src/fixate/drivers/ftdi/libs/libmpsse.dll diff --git a/setup.cfg b/setup.cfg index 394cd09a..55f36e07 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,7 +39,11 @@ install_requires = [options.packages.find] where = src +include_package_data = True +[options.package_data] +# this is only for windows. We can look to add support for other platforms in the future if necessary. +fixate.drivers.ftdi.libs = libmpsse.dll [options.extras_require] gui = diff --git a/src/fixate/drivers/ftdi/_libmpsse.py b/src/fixate/drivers/ftdi/_libmpsse.py new file mode 100644 index 00000000..1668d2b0 --- /dev/null +++ b/src/fixate/drivers/ftdi/_libmpsse.py @@ -0,0 +1,26 @@ +""" Private wrapper for ftdi libmpsse driver. DLL on Windows, .so shared library on *nix. +This is wrapped privately so it can be ommitted from the documentation build. +""" + +import ctypes +import sys +from importlib import resources + +if sys.platform == "win32": + try: + with resources.path("fixate.drivers.ftdi.libs", "libmpsse.dll") as lib_path: + libmpsse = ctypes.WinDLL(lib_path) + except Exception as e: + raise ImportError( + "Unable to find libmpsse.dll.\nThis should have been included in the fixate package installation." + ) from e + +else: + try: + # this won't work at this stage since the .so file isn't included in the package yet. + with resources.path("fixate.drivers.ftdi.libs", "libmpsse.so") as lib_path: + libmpsse = ctypes.cdll.LoadLibrary(lib_path) + except Exception as e: + raise ImportError( + "Unable to find libmpsse.so.\nThis should have been included in the fixate package installation." + ) from e diff --git a/src/fixate/drivers/ftdi/libs/libmpsse.dll b/src/fixate/drivers/ftdi/libs/libmpsse.dll new file mode 100644 index 0000000000000000000000000000000000000000..65fe7c999511ffcd4001b5cfafac37deef3b6cee GIT binary patch literal 161792 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�WcbX) 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 rRROEh!tQjiJUwiW Date: Tue, 3 Mar 2026 11:47:32 +1100 Subject: [PATCH 05/27] ready to start testing with hardware --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 447 ++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 src/fixate/drivers/ftdi/ftdi_mpsse.py diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py new file mode 100644 index 00000000..d717211b --- /dev/null +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -0,0 +1,447 @@ +import ctypes +import logging +from collections.abc import Collection +from enum import IntEnum, IntFlag, StrEnum, unique + +from fixate.core.exceptions import FixateError, InstrumentNotConnected +from fixate.drivers import log_instrument_open +from fixate.drivers.ftdi import FT_HANDLE, FTD2XXError, check_return +from fixate.drivers.ftdi._libmpsse import libmpsse + +# For more information see https://ftdichip.com/wp-content/uploads/2020/08/AN_177_User_Guide_For_LibMPSSE-I2C-1.pdf +# Additionally, the source code for libMPSSE is available as part of this download: https://ftdichip.com/wp-content/uploads/2025/08/libmpsse-windows-1.0.8.zip + +DWORD = ctypes.c_ulong +UCHAR = ctypes.c_ubyte +USHORT = ctypes.c_ushort +LPDWORD = ctypes.POINTER(DWORD) +PCHAR = ctypes.c_char_p + +logger = logging.getLogger(__name__) + + +class I2CError(FixateError): + """Base class for I2C errors.""" + + pass + + +class SPIError(FixateError): + """Base class for SPI errors.""" + + pass + + +class Protocol(StrEnum): + I2C = "i2c" + SPI = "spi" + # TODO - add more protocols as needed + + +@unique +class I2CTransferOptions(IntFlag): + START_BIT = 0x01 + STOP_BIT = 0x02 + BREAK_ON_NACK = 0x04 + NACK_LAST_BYTE = 0x08 + FAST_TRANSFER_BYTES = 0x10 + FAST_TRANSFER_BITS = 0x20 + NO_ADDRESS = 0x40 + + +@unique +class I2CClockRate(IntEnum): + STANDARD_MODE = 100000 + FAST_MODE = 400000 + FAST_MODE_PLUS = 1000000 + HIGH_SPEED_MODE = 3400000 + + +@unique +class I2COptions(IntFlag): + DISABLE_3PHASE_CLOCKING = 0x01 + ENABLE_DRIVE_ONLY_ZERO = 0x02 + # This option is not documented in the user guide, but is mentioned in the source code. + ENABLE_PIN_STATE_CONFIG = 0x10 + # Bits 4 - 31 are reserved + + +class I2CChannelConfig(ctypes.Structure): + _fields_ = [ + ("ClockRate", DWORD), + ("LatencyTimer", UCHAR), + ("Options", DWORD), + ("Pin", DWORD), + ("currentPinState", USHORT), + ] + + +class Mpsse: + """ + Base class for MPSSE drivers. This class should not be instantiated directly, but should be derived from for specific protocols. + Derived classes should implement protocol-specific functionality, but can rely on the base class for connection management and other common functionality. + """ + + INSTR_TYPE = "FTDI" + REGEX_ID = "" # this is only here to 'satisfy' the DriverProtocol interface + + def __init__(self, ftdi_description: str): + self.ftdi_description = ftdi_description + self._handle = FT_HANDLE() + + def get_identity(self) -> str: + """Return identity string representing connected ftdi object""" + return self.ftdi_description + + +class MpsseI2C(Mpsse): + def __init__(self, ftdi_description: str): + super().__init__(ftdi_description) + self._connect() + + def _connect(self): + check_return( + libmpsse.I2C_OpenChannelByDescription( + self.ftdi_description.encode("utf-8"), ctypes.byref(self._handle) + ) + ) + + def configure( + self, config: I2CChannelConfig | None = None, options: I2COptions | None = None + ): + if config is None: + config = I2CChannelConfig( + ClockRate=I2CClockRate.STANDARD_MODE, # standard 100 kHz I2C clock rate, this is the default speed used by pyftdi. + LatencyTimer=16, + Options=options.value if options is not None else 0, + Pin=0, + currentPinState=0, + ) + check_return(libmpsse.I2C_InitChannel(self._handle, ctypes.byref(config))) + + def read( + self, address: int, length: int, options: I2CTransferOptions | None = None + ) -> bytes: + """Read data from an I2C device. + + Args: + address: The 7-bit I2C address of the device to read from. + length: The number of bytes to read. + options: Optional transfer options. See I2CTransferOptions for more information. + + Returns: + The data read from the I2C device. + + Raises: + FTD2XXError: Via check_return if the underlying library call fails. + FT_IO_ERROR will occur if the device does not transfer the expected number of bytes. + FT_DEVICE_NOT_FOUND will occur if the device does not respond. + """ + # libmpsse handles the conversion of the address and read/write bit, so we just need to pass the 7-bit address. + _addr = UCHAR(address) + _buffer = (UCHAR * length)() + _bytes_read = DWORD() + try: + check_return( + libmpsse.I2C_DeviceRead( + self._handle, + _addr, + ctypes.byref(_buffer), + length, + ctypes.byref(_bytes_read), + options.value if options is not None else 0, + ) + ) + except FTD2XXError as e: + if e.args[0] == libmpsse.FT_IO_ERROR: + raise FTD2XXError( + f"Expected to read {length} bytes, but only read {_bytes_read.value} bytes." + ) from e + elif e.args[0] == libmpsse.FT_DEVICE_NOT_FOUND: + raise FTD2XXError(f"Device with address {address} not found.") from e + else: + # Something else happened that isn't documented by the libmpsse library. + raise + return bytes(_buffer[: _bytes_read.value]) + + def write( + self, + address: int, + data: bytes | bytearray | Collection[int], + options: I2CTransferOptions | None = None, + ): + """Write data to an I2C device. + + Args: + address: The 7-bit I2C address of the device to write to. + data: The data to write to the device. + options: Optional transfer options. See I2CTransferOptions for more information. + + Raises: + FTD2XXError: Via check_return if the underlying library call fails. + FT_IO_ERROR will occur if the device does not transfer the expected number of bytes. + FT_DEVICE_NOT_FOUND will occur if the device does not respond. + FT_FAILED_TO_WRITE_DEVICE will occur if the device nACKs a byte and the BREAK_ON_NACK option is specified. + """ + # libmpsse handles the conversion of the address and read/write bit, so we just need to pass the 7-bit address. + _addr = UCHAR(address) + _buffer = (UCHAR * len(data))(*data) + _bytes_written = DWORD() + try: + check_return( + libmpsse.I2C_DeviceWrite( + self._handle, + _addr, + ctypes.byref(_buffer), + len(data), + ctypes.byref(_bytes_written), + options.value if options is not None else 0, + ) + ) + except FTD2XXError as e: + if e.args[0] == libmpsse.FT_IO_ERROR: + raise FTD2XXError( + f"Expected to write {len(data)} bytes, but only wrote {_bytes_written.value} bytes." + ) from e + elif e.args[0] == libmpsse.FT_DEVICE_NOT_FOUND: + raise FTD2XXError(f"Device with address {address} not found.") from e + elif e.args[0] == libmpsse.FT_FAILED_TO_WRITE_DEVICE: + raise FTD2XXError( + f"Device with address {address} NACKed a byte." + ) from e + else: + # Something else happened that isn't documented by the libmpsse library. + raise + + def exchange( + self, + address: int, + data: bytes | bytearray | Collection[int], + write_options: I2CTransferOptions, + read_length: int, + read_options: I2CTransferOptions, + ) -> bytes: + """Write data to an I2C device, then read data from the device with a repeated start. + + Args: + address: The 7-bit I2C address of the device to write to and read from. + data: The data to write to the device before reading. E.g. a register address to read from. + read_length: The number of bytes to read from the device after writing. + write_options: Transfer options for the write operation. See I2CTransferOptions for more information. + read_options: Transfer options for the read operation. See I2CTransferOptions for more information. + + Returns: + The data read from the I2C device after writing. + + Raises: + FTD2XXError + """ + + # First we write to the device with address and the data to write (e.g. register address), then we read from the device with the same address and the specified number of bytes to read. + # the options parameters will determine if start|stop|repeated-start bits are sent. + self.write(address, data, options=write_options) + return self.read(address, read_length, options=read_options) + + def write_gpio(self, direction: int, pin_values: int): + """Set the state of the GPIO pins. + + Args: + direction: Direction of the GPIO pins. 1 for output, 0 for input. + pin_values: Values of the GPIO pins. For output pins, 1 for high, 0 for low. For input pins, this value is ignored. + + Raises: + FTD2XXError: Via check_return if the underlying library call fails. + """ + _dir = UCHAR(direction) + _values = UCHAR(pin_values) + check_return(libmpsse.I2C_WriteGPIO(self._handle, _dir, _values)) + + def read_gpio(self) -> int: + """Read the state of the GPIO pins. + + Returns: + The state of the GPIO pins. For output pins, 1 for high, 0 for low. For input pins, 1 for high, 0 for low. + + Raises: + FTD2XXError: Via check_return if the underlying library call fails. + """ + _values = UCHAR() + check_return(libmpsse.I2C_ReadGPIO(self._handle, ctypes.byref(_values))) + return _values.value + + def get_simple_interface(self, address: int) -> "MpsseI2CSimpleInterface": + """Get a simple interface to the I2C device similar to the port concept used by pyftdi. This is not intended to be a + full-featured interface, but can be useful for simple use cases where the full flexibility of the underlying library is not needed. + + Returns: + An instance of MpsseI2CSimpleInterface that provides a simplified interface to the I2C device. + """ + + return MpsseI2CSimpleInterface(self, address) + + def close(self): + check_return(libmpsse.I2C_CloseChannel(self._handle)) + + +class MpsseI2CSimpleInterface: + """A simple interface to an I2C device that provides basic read and write functionality without requiring the user to specify transfer options or other parameters. + This is intended to be used for simple use cases where the full flexibility of the underlying library is not needed. Similar to the pyftdi library. + """ + + def __init__(self, main_interface: MpsseI2C, address: int): + self._main_interface = main_interface + self._address = address + + def read(self, length: int) -> bytes: + """Read data from the I2C device. + + This method uses default transfer options that should be suitable for most use cases. If you need more control over the transfer, you can use the read method of the main interface MpsseI2C directly. + + Args: + length: The number of bytes to read. + + Returns: + The data read from the I2C device. + + Raises: + FTD2XXError + """ + + options = ( + I2CTransferOptions.START_BIT + | I2CTransferOptions.STOP_BIT + | I2CTransferOptions.BREAK_ON_NACK + ) + return self._main_interface.read(self._address, length, options=options) + + def read_from(self, register: int, length: int) -> bytes: + """Read data from a specific register of the I2C device. + + This method uses default transfer options that should be suitable for most use cases. If you need more control over the transfer, you can use the exchange method of the main interface MpsseI2C directly. + + Args: + register: The register address to read from. + length: The number of bytes to read. + + Returns: + The data read from the specified register of the I2C device. + + Raises: + FTD2XXError + """ + # these options will result in a repeated start between the write and read operations. + write_options = I2CTransferOptions.START_BIT | I2CTransferOptions.BREAK_ON_NACK + read_options = ( + I2CTransferOptions.START_BIT + | I2CTransferOptions.STOP_BIT + | I2CTransferOptions.BREAK_ON_NACK + ) + return self._main_interface.exchange( + self._address, + bytes([register]), + write_options=write_options, + read_length=length, + read_options=read_options, + ) + + def write(self, data: bytes | bytearray | Collection[int]): + """Write data to the I2C device. + + This method uses default transfer options that should be suitable for most use cases. If you need more control over the transfer, you can use the write method of the main interface MpsseI2C directly. + + Args: + data: The data to write to the I2C device. + + Raises: + FTD2XXError + """ + + options = ( + I2CTransferOptions.START_BIT + | I2CTransferOptions.STOP_BIT + | I2CTransferOptions.BREAK_ON_NACK + ) + return self._main_interface.write(self._address, data, options=options) + + def write_to(self, register: int, data: bytes | bytearray | Collection[int]): + """Write data to a specific register of the I2C device. + + This method uses default transfer options that should be suitable for most use cases. If you need more control over the transfer, you can use the exchange method of the main interface MpsseI2C directly. + + Args: + register: The register address to write to. + data: The data to write to the specified register of the I2C device. + + Raises: + FTD2XXError + """ + # TODO - might need to consider the possibility of the register being multiple bytes, but for now assume it's just one byte. + + write_options = ( + I2CTransferOptions.START_BIT + | I2CTransferOptions.STOP_BIT + | I2CTransferOptions.BREAK_ON_NACK + ) + return self._main_interface.write( + self._address, bytes([register]) + bytes(data), options=write_options + ) + + +@unique +class SPITransferOptions(IntEnum): + # TODO - complete me + def __init__(self, value): + raise NotImplementedError("SPI support not yet implemented.") + + +class MpsseSPI(Mpsse): + INSTR_TYPE = "FTDI" + # TODO - complete me + + +def open(protocol: Protocol | str, ftdi_description: str) -> Mpsse: + """Open an MPSSE device with the given protocol and description. + + Args: + protocol: The protocol to use with the device. This determines which MPSSE class to instantiate. + ftdi_description: The description of the device to open. This is the "Description" field from the D2XX API (aka). + + Returns: + An instance of the appropriate MPSSE class for the given protocol, initialized with the device corresponding to the given description. + + Raises: + InstrumentNotConnected: If no device with the given description is found. + ValueError: If an unsupported protocol is specified. + """ + + if protocol == Protocol.I2C or protocol == "i2c": + try: + driver = MpsseI2C(ftdi_description) + except FTD2XXError: + raise InstrumentNotConnected( + f"FTDI device with description '{ftdi_description}' not found." + ) + elif protocol == Protocol.SPI or protocol == "spi": + # TODO - implement me + raise NotImplementedError("SPI support not yet implemented.") + else: + raise ValueError( + f"Unsupported protocol '{protocol}'. Supported protocols are: {[p.value for p in Protocol]}." + ) + + log_instrument_open(driver) + return driver + + +def lib_versions() -> tuple[int, int]: + """ + Get the versions of the libMPSSE and libftdi libraries. + Returns: + A tuple containing the libMPSSE version and the libftdi version, both as integers in the format 0xAABBCCDD where AA is the major version, BB is the minor version, CC is the patch version, and DD is the build number. + """ + mpsse_version = DWORD(0) + libftdi_version = DWORD(0) + + libmpsse.Ver_libMPSSE(ctypes.byref(mpsse_version), ctypes.byref(libftdi_version)) + + return mpsse_version.value, libftdi_version.value From fe841d89d6516e1e0519708f18d12f58cad43cfc Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Thu, 5 Mar 2026 10:46:23 +1100 Subject: [PATCH 06/27] add not implemented error for SPI class --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index d717211b..6a0b7f97 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -397,6 +397,8 @@ def __init__(self, value): class MpsseSPI(Mpsse): INSTR_TYPE = "FTDI" # TODO - complete me + def __init__(self, ftdi_description: str): + raise NotImplementedError("SPI support not yet implemented.") def open(protocol: Protocol | str, ftdi_description: str) -> Mpsse: From c7c3a9d80bcb890a777f39049777b211ccff3f94 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Thu, 5 Mar 2026 10:59:01 +1100 Subject: [PATCH 07/27] update black to be able to parse current python features --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b313588a..7ab7826a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,6 @@ repos: hooks: - id: check-yaml - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 25.12.0 hooks: - id: black From e3d100118afed8e555bc2bd6516d918d44780147 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Thu, 5 Mar 2026 11:01:14 +1100 Subject: [PATCH 08/27] use generic type to make open method a little cleaner --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 33 +++++++++++++-------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index 6a0b7f97..e1b91815 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -2,6 +2,7 @@ import logging from collections.abc import Collection from enum import IntEnum, IntFlag, StrEnum, unique +from typing import Callable, TypeVar from fixate.core.exceptions import FixateError, InstrumentNotConnected from fixate.drivers import log_instrument_open @@ -396,39 +397,37 @@ def __init__(self, value): class MpsseSPI(Mpsse): INSTR_TYPE = "FTDI" + # TODO - complete me def __init__(self, ftdi_description: str): raise NotImplementedError("SPI support not yet implemented.") -def open(protocol: Protocol | str, ftdi_description: str) -> Mpsse: - """Open an MPSSE device with the given protocol and description. +MPSSE_TYPE = TypeVar("MPSSE_TYPE", bound=Mpsse) + + +def open[MPSSE_TYPE]( + interface: Callable[[str], MPSSE_TYPE], ftdi_description: str +) -> MPSSE_TYPE: + """Open an MPSSE device with the given class/type and description. Args: - protocol: The protocol to use with the device. This determines which MPSSE class to instantiate. + interface: The MPSSE class to instantiate. This determines which MPSSE class to use. ftdi_description: The description of the device to open. This is the "Description" field from the D2XX API (aka). Returns: - An instance of the appropriate MPSSE class for the given protocol, initialized with the device corresponding to the given description. + An instance of the appropriate MPSSE class for the given class/type, for the device corresponding to the given description. Raises: InstrumentNotConnected: If no device with the given description is found. ValueError: If an unsupported protocol is specified. """ - if protocol == Protocol.I2C or protocol == "i2c": - try: - driver = MpsseI2C(ftdi_description) - except FTD2XXError: - raise InstrumentNotConnected( - f"FTDI device with description '{ftdi_description}' not found." - ) - elif protocol == Protocol.SPI or protocol == "spi": - # TODO - implement me - raise NotImplementedError("SPI support not yet implemented.") - else: - raise ValueError( - f"Unsupported protocol '{protocol}'. Supported protocols are: {[p.value for p in Protocol]}." + try: + driver = interface(ftdi_description) + except FTD2XXError: + raise InstrumentNotConnected( + f"FTDI device with description '{ftdi_description}' not found." ) log_instrument_open(driver) From 34ec6fa41ba52abd7326e69c544c4dc2074ff098 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Thu, 5 Mar 2026 11:16:15 +1100 Subject: [PATCH 09/27] correct dll function calls that resulted in an OSError --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index e1b91815..084c25a5 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -147,8 +147,8 @@ def read( libmpsse.I2C_DeviceRead( self._handle, _addr, - ctypes.byref(_buffer), length, + ctypes.byref(_buffer), ctypes.byref(_bytes_read), options.value if options is not None else 0, ) @@ -193,8 +193,8 @@ def write( libmpsse.I2C_DeviceWrite( self._handle, _addr, + len(_buffer), ctypes.byref(_buffer), - len(data), ctypes.byref(_bytes_written), options.value if options is not None else 0, ) From afde6f6e102343ec74138353c29bbcc522023db8 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Thu, 5 Mar 2026 11:36:06 +1100 Subject: [PATCH 10/27] fix error handling --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index 084c25a5..a2c0d74b 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -154,12 +154,14 @@ def read( ) ) except FTD2XXError as e: - if e.args[0] == libmpsse.FT_IO_ERROR: + if e.args[0] == "FT_IO_ERROR": raise FTD2XXError( f"Expected to read {length} bytes, but only read {_bytes_read.value} bytes." ) from e - elif e.args[0] == libmpsse.FT_DEVICE_NOT_FOUND: - raise FTD2XXError(f"Device with address {address} not found.") from e + elif e.args[0] == "FT_DEVICE_NOT_FOUND": + raise FTD2XXError( + f"Device with address {address:#02x} not found." + ) from e else: # Something else happened that isn't documented by the libmpsse library. raise @@ -200,15 +202,17 @@ def write( ) ) except FTD2XXError as e: - if e.args[0] == libmpsse.FT_IO_ERROR: + if e.args[0] == "FT_IO_ERROR": + raise FTD2XXError( + f"Expected to write {len(data)} bytes, but only wrote {_bytes_written.value} bytes. Check device connection." + ) from e + elif e.args[0] == "FT_DEVICE_NOT_FOUND": raise FTD2XXError( - f"Expected to write {len(data)} bytes, but only wrote {_bytes_written.value} bytes." + f"Device with address {address:#02x} not found." ) from e - elif e.args[0] == libmpsse.FT_DEVICE_NOT_FOUND: - raise FTD2XXError(f"Device with address {address} not found.") from e - elif e.args[0] == libmpsse.FT_FAILED_TO_WRITE_DEVICE: + elif e.args[0] == "FT_FAILED_TO_WRITE_DEVICE": raise FTD2XXError( - f"Device with address {address} NACKed a byte." + f"Device with address {address:#02x} NACKed a byte." ) from e else: # Something else happened that isn't documented by the libmpsse library. From 95a9f8c1eabf8937c905d4c07a54e6fa76eedcde Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 9 Mar 2026 08:40:06 +1100 Subject: [PATCH 11/27] add pyftdi like start / stop bools in the simple interface --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 90 +++++++++++++++++++-------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index a2c0d74b..4386755a 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -297,13 +297,15 @@ def __init__(self, main_interface: MpsseI2C, address: int): self._main_interface = main_interface self._address = address - def read(self, length: int) -> bytes: + def read(self, length: int, start: bool = True, stop: bool = True) -> bytes: """Read data from the I2C device. This method uses default transfer options that should be suitable for most use cases. If you need more control over the transfer, you can use the read method of the main interface MpsseI2C directly. Args: length: The number of bytes to read. + start: Whether to send a start bit before the read operation. Default is True. + stop: Whether to send a stop bit after the read operation. Default is True. Returns: The data read from the I2C device. @@ -312,14 +314,22 @@ def read(self, length: int) -> bytes: FTD2XXError """ - options = ( - I2CTransferOptions.START_BIT - | I2CTransferOptions.STOP_BIT - | I2CTransferOptions.BREAK_ON_NACK - ) + options = I2CTransferOptions.BREAK_ON_NACK + if start: + options |= I2CTransferOptions.START_BIT + if stop: + options |= I2CTransferOptions.STOP_BIT + return self._main_interface.read(self._address, length, options=options) - def read_from(self, register: int, length: int) -> bytes: + def read_from( + self, + register: int, + length: int, + start: bool = True, + stop: bool = True, + repeated_start: bool = False, + ) -> bytes: """Read data from a specific register of the I2C device. This method uses default transfer options that should be suitable for most use cases. If you need more control over the transfer, you can use the exchange method of the main interface MpsseI2C directly. @@ -327,6 +337,9 @@ def read_from(self, register: int, length: int) -> bytes: Args: register: The register address to read from. length: The number of bytes to read. + start: Whether to send a start bit before the write operation. Default is True. + stop: Whether to send a stop bit after the read operation. Default is True. + repeated_start: Whether to send a repeated start bit between the write and read operations. Default is False. Returns: The data read from the specified register of the I2C device. @@ -334,13 +347,17 @@ def read_from(self, register: int, length: int) -> bytes: Raises: FTD2XXError """ - # these options will result in a repeated start between the write and read operations. - write_options = I2CTransferOptions.START_BIT | I2CTransferOptions.BREAK_ON_NACK - read_options = ( - I2CTransferOptions.START_BIT - | I2CTransferOptions.STOP_BIT - | I2CTransferOptions.BREAK_ON_NACK - ) + # default will be to break on NACK + write_options = I2CTransferOptions.BREAK_ON_NACK + read_options = I2CTransferOptions.BREAK_ON_NACK + + if start: + write_options |= I2CTransferOptions.START_BIT + if stop: + read_options |= I2CTransferOptions.STOP_BIT + if repeated_start: + read_options |= I2CTransferOptions.START_BIT + return self._main_interface.exchange( self._address, bytes([register]), @@ -349,26 +366,41 @@ def read_from(self, register: int, length: int) -> bytes: read_options=read_options, ) - def write(self, data: bytes | bytearray | Collection[int]): + def write( + self, + data: bytes | bytearray | Collection[int], + start: bool = True, + stop: bool = True, + ): """Write data to the I2C device. This method uses default transfer options that should be suitable for most use cases. If you need more control over the transfer, you can use the write method of the main interface MpsseI2C directly. Args: data: The data to write to the I2C device. + start: Whether to send a start bit before the write operation. Default is True. + stop: Whether to send a stop bit after the write operation. Default is True. Raises: FTD2XXError """ + # default will be to break on NACK. + write_options = I2CTransferOptions(I2CTransferOptions.BREAK_ON_NACK) - options = ( - I2CTransferOptions.START_BIT - | I2CTransferOptions.STOP_BIT - | I2CTransferOptions.BREAK_ON_NACK - ) - return self._main_interface.write(self._address, data, options=options) + if start: + write_options |= I2CTransferOptions.START_BIT + if stop: + write_options |= I2CTransferOptions.STOP_BIT - def write_to(self, register: int, data: bytes | bytearray | Collection[int]): + return self._main_interface.write(self._address, data, options=write_options) + + def write_to( + self, + register: int, + data: bytes | bytearray | Collection[int], + start: bool = True, + stop: bool = True, + ): """Write data to a specific register of the I2C device. This method uses default transfer options that should be suitable for most use cases. If you need more control over the transfer, you can use the exchange method of the main interface MpsseI2C directly. @@ -376,17 +408,21 @@ def write_to(self, register: int, data: bytes | bytearray | Collection[int]): Args: register: The register address to write to. data: The data to write to the specified register of the I2C device. + start: Whether to send a start bit before the write operation. Default is True. + stop: Whether to send a stop bit after the write operation. Default is True. Raises: FTD2XXError """ # TODO - might need to consider the possibility of the register being multiple bytes, but for now assume it's just one byte. + # default will be to break on NACK. + write_options = I2CTransferOptions(I2CTransferOptions.BREAK_ON_NACK) + + if start: + write_options |= I2CTransferOptions.START_BIT + if stop: + write_options |= I2CTransferOptions.STOP_BIT - write_options = ( - I2CTransferOptions.START_BIT - | I2CTransferOptions.STOP_BIT - | I2CTransferOptions.BREAK_ON_NACK - ) return self._main_interface.write( self._address, bytes([register]) + bytes(data), options=write_options ) From 7591271bfc7ecc8cc29c879778923acba0b858b7 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 9 Mar 2026 08:41:59 +1100 Subject: [PATCH 12/27] it doesn't make sense for the options to be optional --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index 4386755a..e99cbbd1 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -120,15 +120,13 @@ def configure( ) check_return(libmpsse.I2C_InitChannel(self._handle, ctypes.byref(config))) - def read( - self, address: int, length: int, options: I2CTransferOptions | None = None - ) -> bytes: + def read(self, address: int, length: int, options: I2CTransferOptions) -> bytes: """Read data from an I2C device. Args: address: The 7-bit I2C address of the device to read from. length: The number of bytes to read. - options: Optional transfer options. See I2CTransferOptions for more information. + options: Transfer options for the read operation. See I2CTransferOptions for more information. Returns: The data read from the I2C device. @@ -171,14 +169,14 @@ def write( self, address: int, data: bytes | bytearray | Collection[int], - options: I2CTransferOptions | None = None, + options: I2CTransferOptions, ): """Write data to an I2C device. Args: address: The 7-bit I2C address of the device to write to. data: The data to write to the device. - options: Optional transfer options. See I2CTransferOptions for more information. + options: Transfer options for the write operation. See I2CTransferOptions for more information. Raises: FTD2XXError: Via check_return if the underlying library call fails. @@ -198,7 +196,7 @@ def write( len(_buffer), ctypes.byref(_buffer), ctypes.byref(_bytes_written), - options.value if options is not None else 0, + options.value, ) ) except FTD2XXError as e: From 438a4b177029cefc317dcefcc5a951abcabb5a4d Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 9 Mar 2026 08:44:26 +1100 Subject: [PATCH 13/27] future work TODO --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index e99cbbd1..a5615d8f 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -425,6 +425,8 @@ def write_to( self._address, bytes([register]) + bytes(data), options=write_options ) + # TODO - add more functionality as needed, e.g. POLLING, GPIO control, clock stretching, etc. + @unique class SPITransferOptions(IntEnum): From 389d2979d83540ca02fa373f0a42ecf67a514f2c Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 9 Mar 2026 12:58:05 +1100 Subject: [PATCH 14/27] make repeated start teh default (as per pyftdi) --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index a5615d8f..f5b45ba3 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -326,7 +326,7 @@ def read_from( length: int, start: bool = True, stop: bool = True, - repeated_start: bool = False, + repeated_start: bool = True, ) -> bytes: """Read data from a specific register of the I2C device. From 8b07986e344e079d53fb6674980531d0ebfdc574 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Wed, 11 Mar 2026 13:41:53 +1100 Subject: [PATCH 15/27] build without a debug print statement --- src/fixate/drivers/ftdi/libs/libmpsse.dll | Bin 161792 -> 161792 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/fixate/drivers/ftdi/libs/libmpsse.dll b/src/fixate/drivers/ftdi/libs/libmpsse.dll index 65fe7c999511ffcd4001b5cfafac37deef3b6cee..042bf605ee4dea5a7a3f8ae3f76298b18d9b3e1c 100644 GIT binary patch delta 23346 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 Date: Wed, 11 Mar 2026 15:53:04 +1100 Subject: [PATCH 16/27] change to I2CError for more clarity in error reporting --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 32 ++++++++++++--------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index f5b45ba3..ce8a2e64 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -132,7 +132,7 @@ def read(self, address: int, length: int, options: I2CTransferOptions) -> bytes: The data read from the I2C device. Raises: - FTD2XXError: Via check_return if the underlying library call fails. + I2CError: Via FTD2XXError in check_return if the underlying library call fails. FT_IO_ERROR will occur if the device does not transfer the expected number of bytes. FT_DEVICE_NOT_FOUND will occur if the device does not respond. """ @@ -153,13 +153,11 @@ def read(self, address: int, length: int, options: I2CTransferOptions) -> bytes: ) except FTD2XXError as e: if e.args[0] == "FT_IO_ERROR": - raise FTD2XXError( + raise I2CError( f"Expected to read {length} bytes, but only read {_bytes_read.value} bytes." ) from e elif e.args[0] == "FT_DEVICE_NOT_FOUND": - raise FTD2XXError( - f"Device with address {address:#02x} not found." - ) from e + raise I2CError(f"Device with address {address:#02x} not found.") from e else: # Something else happened that isn't documented by the libmpsse library. raise @@ -179,7 +177,7 @@ def write( options: Transfer options for the write operation. See I2CTransferOptions for more information. Raises: - FTD2XXError: Via check_return if the underlying library call fails. + I2CError: Via FTD2XXError in check_return if the underlying library call fails. FT_IO_ERROR will occur if the device does not transfer the expected number of bytes. FT_DEVICE_NOT_FOUND will occur if the device does not respond. FT_FAILED_TO_WRITE_DEVICE will occur if the device nACKs a byte and the BREAK_ON_NACK option is specified. @@ -201,15 +199,13 @@ def write( ) except FTD2XXError as e: if e.args[0] == "FT_IO_ERROR": - raise FTD2XXError( + raise I2CError( f"Expected to write {len(data)} bytes, but only wrote {_bytes_written.value} bytes. Check device connection." ) from e elif e.args[0] == "FT_DEVICE_NOT_FOUND": - raise FTD2XXError( - f"Device with address {address:#02x} not found." - ) from e + raise I2CError(f"Device with address {address:#02x} not found.") from e elif e.args[0] == "FT_FAILED_TO_WRITE_DEVICE": - raise FTD2XXError( + raise I2CError( f"Device with address {address:#02x} NACKed a byte." ) from e else: @@ -237,7 +233,7 @@ def exchange( The data read from the I2C device after writing. Raises: - FTD2XXError + I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ # First we write to the device with address and the data to write (e.g. register address), then we read from the device with the same address and the specified number of bytes to read. @@ -253,7 +249,7 @@ def write_gpio(self, direction: int, pin_values: int): pin_values: Values of the GPIO pins. For output pins, 1 for high, 0 for low. For input pins, this value is ignored. Raises: - FTD2XXError: Via check_return if the underlying library call fails. + I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ _dir = UCHAR(direction) _values = UCHAR(pin_values) @@ -266,7 +262,7 @@ def read_gpio(self) -> int: The state of the GPIO pins. For output pins, 1 for high, 0 for low. For input pins, 1 for high, 0 for low. Raises: - FTD2XXError: Via check_return if the underlying library call fails. + I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ _values = UCHAR() check_return(libmpsse.I2C_ReadGPIO(self._handle, ctypes.byref(_values))) @@ -309,7 +305,7 @@ def read(self, length: int, start: bool = True, stop: bool = True) -> bytes: The data read from the I2C device. Raises: - FTD2XXError + I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ options = I2CTransferOptions.BREAK_ON_NACK @@ -343,7 +339,7 @@ def read_from( The data read from the specified register of the I2C device. Raises: - FTD2XXError + I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ # default will be to break on NACK write_options = I2CTransferOptions.BREAK_ON_NACK @@ -380,7 +376,7 @@ def write( stop: Whether to send a stop bit after the write operation. Default is True. Raises: - FTD2XXError + I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ # default will be to break on NACK. write_options = I2CTransferOptions(I2CTransferOptions.BREAK_ON_NACK) @@ -410,7 +406,7 @@ def write_to( stop: Whether to send a stop bit after the write operation. Default is True. Raises: - FTD2XXError + I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ # TODO - might need to consider the possibility of the register being multiple bytes, but for now assume it's just one byte. # default will be to break on NACK. From 6d8aca44339860785cc6c653453b4bd34f4878b7 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 17 Mar 2026 11:13:45 +1100 Subject: [PATCH 17/27] add retry logic akin to that in pyftdi library as I don't want to introduce any increase in test retries if it can be helped --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 190 ++++++++++++++++++-------- 1 file changed, 133 insertions(+), 57 deletions(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index ce8a2e64..be7cbc0f 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -96,9 +96,10 @@ def get_identity(self) -> str: class MpsseI2C(Mpsse): - def __init__(self, ftdi_description: str): + def __init__(self, ftdi_description: str, retries: int = 3): super().__init__(ftdi_description) self._connect() + self._retries = retries def _connect(self): check_return( @@ -137,31 +138,47 @@ def read(self, address: int, length: int, options: I2CTransferOptions) -> bytes: FT_DEVICE_NOT_FOUND will occur if the device does not respond. """ # libmpsse handles the conversion of the address and read/write bit, so we just need to pass the 7-bit address. - _addr = UCHAR(address) - _buffer = (UCHAR * length)() - _bytes_read = DWORD() - try: - check_return( - libmpsse.I2C_DeviceRead( - self._handle, - _addr, - length, - ctypes.byref(_buffer), - ctypes.byref(_bytes_read), - options.value if options is not None else 0, + addr = UCHAR(address) + buffer = (UCHAR * length)() + bytes_read = DWORD() + # the pyftdi library has in-built retry logic, it's not known if retries are done frequently in our usage, + # but we'll do a similar thing here. + for attempt in range(self._retries): + try: + check_return( + libmpsse.I2C_DeviceRead( + self._handle, + addr, + length, + ctypes.byref(buffer), + ctypes.byref(bytes_read), + options.value, + ) ) - ) - except FTD2XXError as e: - if e.args[0] == "FT_IO_ERROR": - raise I2CError( - f"Expected to read {length} bytes, but only read {_bytes_read.value} bytes." - ) from e - elif e.args[0] == "FT_DEVICE_NOT_FOUND": - raise I2CError(f"Device with address {address:#02x} not found.") from e - else: - # Something else happened that isn't documented by the libmpsse library. - raise - return bytes(_buffer[: _bytes_read.value]) + # break out to return. Having the return here upsets pylance. + break + except FTD2XXError as e: + if e.args[0] == "FT_IO_ERROR": + # this is a retriable error + if attempt < self._retries - 1: + print( + f"Attempt {attempt + 1} failed with error {e.args[0]}. Retrying..." + ) + continue + raise I2CError( + f"Expected to read {length} bytes, but only read {bytes_read.value} bytes." + ) from e + elif e.args[0] == "FT_DEVICE_NOT_FOUND": + raise I2CError( + f"Device with address {address:#02x} not found." + ) from e + else: + # Something else happened that isn't documented by the libmpsse library. + raise I2CError( + f"An unexpected error occurred while reading from device with address {address:#02x}." + ) from e + + return bytes(buffer[: bytes_read.value]) def write( self, @@ -183,34 +200,44 @@ def write( FT_FAILED_TO_WRITE_DEVICE will occur if the device nACKs a byte and the BREAK_ON_NACK option is specified. """ # libmpsse handles the conversion of the address and read/write bit, so we just need to pass the 7-bit address. - _addr = UCHAR(address) - _buffer = (UCHAR * len(data))(*data) - _bytes_written = DWORD() - try: - check_return( - libmpsse.I2C_DeviceWrite( - self._handle, - _addr, - len(_buffer), - ctypes.byref(_buffer), - ctypes.byref(_bytes_written), - options.value, + addr = UCHAR(address) + buffer = (UCHAR * len(data))(*data) + bytes_written = DWORD() + # the pyftdi library has in-built retry logic, it's not known if retries are done frequently in our usage, + # but we'll do a similar thing here. + for attempt in range(self._retries): + try: + check_return( + libmpsse.I2C_DeviceWrite( + self._handle, + addr, + len(buffer), + ctypes.byref(buffer), + ctypes.byref(bytes_written), + options.value, + ) ) - ) - except FTD2XXError as e: - if e.args[0] == "FT_IO_ERROR": - raise I2CError( - f"Expected to write {len(data)} bytes, but only wrote {_bytes_written.value} bytes. Check device connection." - ) from e - elif e.args[0] == "FT_DEVICE_NOT_FOUND": - raise I2CError(f"Device with address {address:#02x} not found.") from e - elif e.args[0] == "FT_FAILED_TO_WRITE_DEVICE": - raise I2CError( - f"Device with address {address:#02x} NACKed a byte." - ) from e - else: - # Something else happened that isn't documented by the libmpsse library. - raise + return + except FTD2XXError as e: + if e.args[0] in ["FT_IO_ERROR", "FT_FAILED_TO_WRITE_DEVICE"]: + # these are retriable errors + if attempt < self._retries - 1: + print( + f"Attempt {attempt + 1} failed with error {e.args[0]}. Retrying..." + ) + continue + raise I2CError( + f"Expected to write {len(data)} bytes, but only wrote {bytes_written.value} bytes." + ) from e + elif e.args[0] == "FT_DEVICE_NOT_FOUND": + raise I2CError( + f"Device with address {address:#02x} not found." + ) from e + else: + # Something else happened that isn't documented by the libmpsse library. + raise I2CError( + f"An unexpected error occurred while writing to device with address {address:#02x}." + ) from e def exchange( self, @@ -235,11 +262,60 @@ def exchange( Raises: I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ - - # First we write to the device with address and the data to write (e.g. register address), then we read from the device with the same address and the specified number of bytes to read. - # the options parameters will determine if start|stop|repeated-start bits are sent. - self.write(address, data, options=write_options) - return self.read(address, read_length, options=read_options) + addr = UCHAR(address) + write_buffer = (UCHAR * len(data))(*data) + bytes_written = DWORD() + read_buffer = (UCHAR * read_length)() + bytes_read = DWORD() + # can't use this class's read and write methods since they have retry logic that we don't want to use here, + # so we need to call the underlying library methods directly. + for attempt in range(self._retries): + try: + check_return( + libmpsse.I2C_DeviceWrite( + self._handle, + addr, + len(write_buffer), + ctypes.byref(write_buffer), + ctypes.byref(bytes_written), + write_options.value, + ) + ) + check_return( + libmpsse.I2C_DeviceRead( + self._handle, + addr, + read_length, + ctypes.byref(read_buffer), + ctypes.byref(bytes_read), + read_options.value, + ) + ) + # break out to return. Having the return here upsets pylance. + break + + except FTD2XXError as e: + if e.args[0] in ["FT_IO_ERROR", "FT_FAILED_TO_WRITE_DEVICE"]: + # these are retriable errors + if attempt < self._retries - 1: + print( + f"Attempt {attempt + 1} failed with error {e.args[0]}. Retrying..." + ) + continue + raise I2CError( + f"Expected to write {len(data)} bytes and read {read_length} bytes, but wrote {len(data)} bytes and read {len(read_buffer)} bytes." + ) from e + elif e.args[0] == "FT_DEVICE_NOT_FOUND": + raise I2CError( + f"Device with address {address:#02x} not found." + ) from e + else: + # Something else happened that isn't documented by the libmpsse library. + raise I2CError( + f"An unexpected error occurred while exchanging data with device with address {address:#02x}." + ) from e + + return bytes(read_buffer[: bytes_read.value]) def write_gpio(self, direction: int, pin_values: int): """Set the state of the GPIO pins. From 5fc0b277f40b92284b2a91a93c2f1ac44736281e Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 17 Mar 2026 11:19:15 +1100 Subject: [PATCH 18/27] switch to NAK last byte --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index be7cbc0f..e98adffd 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -383,8 +383,9 @@ def read(self, length: int, start: bool = True, stop: bool = True) -> bytes: Raises: I2CError: Via FTD2XXError in check_return if the underlying library call fails. """ - - options = I2CTransferOptions.BREAK_ON_NACK + # default will be to NACK the last byte, which is a common convention for I2C reads + # and what is done in the pyftdi library. + options = I2CTransferOptions.NACK_LAST_BYTE if start: options |= I2CTransferOptions.START_BIT if stop: @@ -419,7 +420,9 @@ def read_from( """ # default will be to break on NACK write_options = I2CTransferOptions.BREAK_ON_NACK - read_options = I2CTransferOptions.BREAK_ON_NACK + # default will be to NACK the last byte, which is a common convention for I2C reads + # and what is done in the pyftdi library. + read_options = I2CTransferOptions.NACK_LAST_BYTE if start: write_options |= I2CTransferOptions.START_BIT From 99dff592ed47905502bc2891ef9ad25b766b9491 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 17 Mar 2026 11:22:14 +1100 Subject: [PATCH 19/27] clean up handle after close --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index e98adffd..663656ff 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -356,6 +356,7 @@ def get_simple_interface(self, address: int) -> "MpsseI2CSimpleInterface": def close(self): check_return(libmpsse.I2C_CloseChannel(self._handle)) + self._handle = FT_HANDLE() # reset handle to default value class MpsseI2CSimpleInterface: From 05b8bae0f1cd592fbcbe7cc2c43648e997c56ad2 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 17 Mar 2026 11:31:34 +1100 Subject: [PATCH 20/27] add comment RE change to library --- src/fixate/drivers/ftdi/ftdi_mpsse.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/ftdi_mpsse.py index 663656ff..fbfc4028 100644 --- a/src/fixate/drivers/ftdi/ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/ftdi_mpsse.py @@ -11,6 +11,7 @@ # For more information see https://ftdichip.com/wp-content/uploads/2020/08/AN_177_User_Guide_For_LibMPSSE-I2C-1.pdf # Additionally, the source code for libMPSSE is available as part of this download: https://ftdichip.com/wp-content/uploads/2025/08/libmpsse-windows-1.0.8.zip +# This source code has been edited to include an method to open by description, which didn't come in the box. DWORD = ctypes.c_ulong UCHAR = ctypes.c_ubyte From 46e216e7d2ec0883fa802c01f67ef3bf169247c8 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 17 Mar 2026 11:44:52 +1100 Subject: [PATCH 21/27] make ftdi_mpsse.py private and only expose parts of the module that are complete --- src/fixate/drivers/ftdi/__init__.py | 10 ++++++++++ .../drivers/ftdi/{ftdi_mpsse.py => _ftdi_mpsse.py} | 0 2 files changed, 10 insertions(+) rename src/fixate/drivers/ftdi/{ftdi_mpsse.py => _ftdi_mpsse.py} (100%) diff --git a/src/fixate/drivers/ftdi/__init__.py b/src/fixate/drivers/ftdi/__init__.py index e3653553..bfaab59e 100644 --- a/src/fixate/drivers/ftdi/__init__.py +++ b/src/fixate/drivers/ftdi/__init__.py @@ -448,3 +448,13 @@ def open(ftdi_description="") -> FTDI2xx: raise InstrumentNotConnected( f"No valid ftdi found by description '{ftdi_description}'" ) + + +from fixate.drivers.ftdi._ftdi_mpsse import ( # noqa: I001 - we don't want these imports to be split up + I2CTransferOptions as I2CTransferOptions, + I2CClockRate as I2CClockRate, + I2CChannelConfig as I2CChannelConfig, + MpsseI2C as MpsseI2C, + MpsseI2CSimpleInterface as MpsseI2CSimpleInterface, + open as open_mpsse, # explicitly named to avoid conflict with open() at ftdi level. # noqa: F401 +) diff --git a/src/fixate/drivers/ftdi/ftdi_mpsse.py b/src/fixate/drivers/ftdi/_ftdi_mpsse.py similarity index 100% rename from src/fixate/drivers/ftdi/ftdi_mpsse.py rename to src/fixate/drivers/ftdi/_ftdi_mpsse.py From a888668ebd7a24b93ef79dc34124e4529fc354fb Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Tue, 17 Mar 2026 11:48:41 +1100 Subject: [PATCH 22/27] update release notes and make version line up with these --- docs/release-notes.rst | 1 + src/fixate/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes.rst b/docs/release-notes.rst index 5c1da9b8..0a172c3b 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -13,6 +13,7 @@ Major Changes New Features ############ +- FTDI MPSSE I2C functionality that replaces pyftdi with libmpsse. Improvements ############ diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index 959ec028..59d3788c 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -32,4 +32,4 @@ from fixate.main import run_main_program as run -__version__ = "0.6.4" +__version__ = "0.6.5" From f20d3b7cfbc4e7353fba6bf3b0889ba928c939d6 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Wed, 18 Mar 2026 07:50:18 +1100 Subject: [PATCH 23/27] ignore a mypy issue because I don't want to deal with it in this branch --- src/fixate/drivers/ftdi/_ftdi_mpsse.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fixate/drivers/ftdi/_ftdi_mpsse.py b/src/fixate/drivers/ftdi/_ftdi_mpsse.py index fbfc4028..516295b1 100644 --- a/src/fixate/drivers/ftdi/_ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/_ftdi_mpsse.py @@ -547,7 +547,8 @@ def open[MPSSE_TYPE]( f"FTDI device with description '{ftdi_description}' not found." ) - log_instrument_open(driver) + # ignore the below for now, it can be fixed as part of a future mypy targeted branch. + log_instrument_open(driver) # type: ignore return driver From 61668526ecaf4b92077a638459a37cc7ab3ead1e Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Wed, 18 Mar 2026 08:04:40 +1100 Subject: [PATCH 24/27] update the ignore structure after changing the module layout for ftdi. I don't want to have to fix the mypy issues as this issue is affecting production, also had to update mypy version for the files to be excluded properly for some reason. --- mypy.ini | 8 +++++++- tox.ini | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/mypy.ini b/mypy.ini index 757f7204..d0e6e61e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -67,8 +67,14 @@ exclude = (?x) __init__.py |helper.py ) + |ftdi/ + ( + __init__.py + |_ftdi_mpsse.py + |_ftdi_py.py + |_libmpsse.py + ) |__init__.py - |ftdi.py ) ) ) diff --git a/tox.ini b/tox.ini index 8b3d4bd4..5cdf10a6 100644 --- a/tox.ini +++ b/tox.ini @@ -30,7 +30,7 @@ markers = [testenv:mypy] basepython = python3 -deps = mypy==1.10.0 +deps = mypy==1.19.1 # mypy gives different results if you actually install the stuff before you check it # separate cache to stop weirdness around sharing cache with other instances of mypy commands = mypy --cache-dir="{envdir}/mypy_cache" --config-file=mypy.ini From a58fc70a9401add3fd7eb6815c6a4b07e8bab99f Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Wed, 18 Mar 2026 08:20:15 +1100 Subject: [PATCH 25/27] switch prints to logger calls --- src/fixate/drivers/ftdi/_ftdi_mpsse.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fixate/drivers/ftdi/_ftdi_mpsse.py b/src/fixate/drivers/ftdi/_ftdi_mpsse.py index 516295b1..61966db4 100644 --- a/src/fixate/drivers/ftdi/_ftdi_mpsse.py +++ b/src/fixate/drivers/ftdi/_ftdi_mpsse.py @@ -162,7 +162,7 @@ def read(self, address: int, length: int, options: I2CTransferOptions) -> bytes: if e.args[0] == "FT_IO_ERROR": # this is a retriable error if attempt < self._retries - 1: - print( + logger.warning( f"Attempt {attempt + 1} failed with error {e.args[0]}. Retrying..." ) continue @@ -223,7 +223,7 @@ def write( if e.args[0] in ["FT_IO_ERROR", "FT_FAILED_TO_WRITE_DEVICE"]: # these are retriable errors if attempt < self._retries - 1: - print( + logger.warning( f"Attempt {attempt + 1} failed with error {e.args[0]}. Retrying..." ) continue @@ -299,7 +299,7 @@ def exchange( if e.args[0] in ["FT_IO_ERROR", "FT_FAILED_TO_WRITE_DEVICE"]: # these are retriable errors if attempt < self._retries - 1: - print( + logger.warning( f"Attempt {attempt + 1} failed with error {e.args[0]}. Retrying..." ) continue From 7700d68c9e915ba738d9327f809e62b42e720622 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Wed, 18 Mar 2026 10:01:21 +1100 Subject: [PATCH 26/27] make tox black version the same as precommit.yaml version --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 5cdf10a6..8a4b57f1 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ commands = [testenv:black] basepython = python3 skip_install = true -deps = black==22.3.0 +deps = black==25.12.0 commands = black --check src test scripts {posargs} [pytest] From 0f1d783fe6481eef35c818f6252609135214d71c Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Wed, 18 Mar 2026 10:39:29 +1100 Subject: [PATCH 27/27] run black on all files since the version was updated and some new default rules are being enforced --- examples/function_generator.py | 1 + examples/jig_driver.py | 1 + examples/programmable_power_supply.py | 1 + examples/test_script.py | 1 + src/fixate/config/__init__.py | 1 + src/fixate/core/checks.py | 1 + src/fixate/core/ui.py | 1 + src/fixate/drivers/daq/daqmx.py | 1 + src/fixate/drivers/dmm/__init__.py | 1 + src/fixate/drivers/ftdi/_ftdi.py | 2 +- src/fixate/drivers/ftdi/_libmpsse.py | 2 +- src/fixate/drivers/funcgen/__init__.py | 1 + src/fixate/drivers/handlers.py | 1 + src/fixate/drivers/lcr/__init__.py | 1 + src/fixate/drivers/pps/bk_178x.py | 6 +++--- src/fixate/reporting/csv.py | 1 + src/fixate/ui_cmdline/kbhit.py | 4 ++-- test/drivers/test_keysight_DSOX1102G.py | 4 +--- test/manual/test_serial_numbers.py | 1 + 19 files changed, 22 insertions(+), 10 deletions(-) diff --git a/examples/function_generator.py b/examples/function_generator.py index b660f047..a57e24de 100644 --- a/examples/function_generator.py +++ b/examples/function_generator.py @@ -1,6 +1,7 @@ """ Examples on how to use the function generator driver """ + import time from fixate.core.common import TestClass, TestList diff --git a/examples/jig_driver.py b/examples/jig_driver.py index 6d56cdf6..977318a5 100644 --- a/examples/jig_driver.py +++ b/examples/jig_driver.py @@ -2,6 +2,7 @@ This file is just a test playground that shows how the update jig classes will fit together. """ + from __future__ import annotations from dataclasses import dataclass, field from fixate import ( diff --git a/examples/programmable_power_supply.py b/examples/programmable_power_supply.py index da112be2..7f263e9d 100644 --- a/examples/programmable_power_supply.py +++ b/examples/programmable_power_supply.py @@ -1,6 +1,7 @@ """ Examples on how to use the programmable power supply driver works """ + import time from fixate.core.common import TestClass, TestList diff --git a/examples/test_script.py b/examples/test_script.py index 185283c4..330b0339 100644 --- a/examples/test_script.py +++ b/examples/test_script.py @@ -1,6 +1,7 @@ """ This is a test script that shows basic use case for the fixate library """ + from fixate.core.common import TestClass, TestList from fixate.core.checks import * from fixate.core.ui import * diff --git a/src/fixate/config/__init__.py b/src/fixate/config/__init__.py index 98b0cfe7..7a66238a 100644 --- a/src/fixate/config/__init__.py +++ b/src/fixate/config/__init__.py @@ -4,6 +4,7 @@ Drivers are hard coded into the config to prevent issues arising from auto discovery Must ensure driver imports are infallible to prevent program crash on start """ + from fixate.config.helper import ( load_dict_config, load_yaml_config, diff --git a/src/fixate/core/checks.py b/src/fixate/core/checks.py index a4eb09a7..fef2f852 100644 --- a/src/fixate/core/checks.py +++ b/src/fixate/core/checks.py @@ -2,6 +2,7 @@ This module is used to allow for tests to test values against criteria. It should implement necessary logging functions and report success or failure. """ + from dataclasses import dataclass, field from typing import Any, Callable, Iterable, Optional import logging diff --git a/src/fixate/core/ui.py b/src/fixate/core/ui.py index 602f38cc..53729dd0 100644 --- a/src/fixate/core/ui.py +++ b/src/fixate/core/ui.py @@ -1,6 +1,7 @@ """ This module details user input api """ + import time from queue import Queue, Empty from pubsub import pub diff --git a/src/fixate/drivers/daq/daqmx.py b/src/fixate/drivers/daq/daqmx.py index ade5d720..cf9b59ef 100644 --- a/src/fixate/drivers/daq/daqmx.py +++ b/src/fixate/drivers/daq/daqmx.py @@ -15,6 +15,7 @@ C:\Program Files\National Instruments\NI-DAQ\DAQmx ANSI C Dev\include\NIDAQmx.h """ + from collections import namedtuple from fixate.core.common import ExcThread from queue import Queue, Empty diff --git a/src/fixate/drivers/dmm/__init__.py b/src/fixate/drivers/dmm/__init__.py index 72f47d6d..9a81b165 100644 --- a/src/fixate/drivers/dmm/__init__.py +++ b/src/fixate/drivers/dmm/__init__.py @@ -10,6 +10,7 @@ dmm.measure(*mode, **mode_params) dmm.reset() """ + import pyvisa import fixate.drivers diff --git a/src/fixate/drivers/ftdi/_ftdi.py b/src/fixate/drivers/ftdi/_ftdi.py index e4da10d2..4f305e82 100644 --- a/src/fixate/drivers/ftdi/_ftdi.py +++ b/src/fixate/drivers/ftdi/_ftdi.py @@ -1,4 +1,4 @@ -""" Private wrapper for ftdi driver. DLL on Windows, .so shared library on *nix. +"""Private wrapper for ftdi driver. DLL on Windows, .so shared library on *nix. This is wrapped privately so it can be ommitted from the documentation build. """ diff --git a/src/fixate/drivers/ftdi/_libmpsse.py b/src/fixate/drivers/ftdi/_libmpsse.py index 1668d2b0..deaafdfc 100644 --- a/src/fixate/drivers/ftdi/_libmpsse.py +++ b/src/fixate/drivers/ftdi/_libmpsse.py @@ -1,4 +1,4 @@ -""" Private wrapper for ftdi libmpsse driver. DLL on Windows, .so shared library on *nix. +"""Private wrapper for ftdi libmpsse driver. DLL on Windows, .so shared library on *nix. This is wrapped privately so it can be ommitted from the documentation build. """ diff --git a/src/fixate/drivers/funcgen/__init__.py b/src/fixate/drivers/funcgen/__init__.py index 8e874683..de5d9c5f 100644 --- a/src/fixate/drivers/funcgen/__init__.py +++ b/src/fixate/drivers/funcgen/__init__.py @@ -22,6 +22,7 @@ output_ch3 output_ch4 """ + import pyvisa import fixate.drivers diff --git a/src/fixate/drivers/handlers.py b/src/fixate/drivers/handlers.py index 0cc64021..df6faf20 100644 --- a/src/fixate/drivers/handlers.py +++ b/src/fixate/drivers/handlers.py @@ -2,6 +2,7 @@ This module implements concrete AddressHandler type, that can be used to implement IO for the fixate.core.switching module. """ + from __future__ import annotations from typing import Sequence, Optional diff --git a/src/fixate/drivers/lcr/__init__.py b/src/fixate/drivers/lcr/__init__.py index f33328a2..e3a81381 100644 --- a/src/fixate/drivers/lcr/__init__.py +++ b/src/fixate/drivers/lcr/__init__.py @@ -5,6 +5,7 @@ Functions are dictated by the metaclass in helper.py """ + import pyvisa import fixate.drivers diff --git a/src/fixate/drivers/pps/bk_178x.py b/src/fixate/drivers/pps/bk_178x.py index a81100d7..26b2a782 100644 --- a/src/fixate/drivers/pps/bk_178x.py +++ b/src/fixate/drivers/pps/bk_178x.py @@ -146,9 +146,9 @@ def _packet_encode(self, command, *data_tuples): packet[2] = command packet_index = 3 for data, num_bytes in data_tuples: - packet[ - packet_index : packet_index + num_bytes - ] = self._little_endian_encode(data)[0:num_bytes] + packet[packet_index : packet_index + num_bytes] = ( + self._little_endian_encode(data)[0:num_bytes] + ) packet_index += num_bytes if packet_index >= self.PACKET_LENGTH: raise ValueError("Too many bytes to pack into packet") diff --git a/src/fixate/reporting/csv.py b/src/fixate/reporting/csv.py index bb493307..3e0f537b 100644 --- a/src/fixate/reporting/csv.py +++ b/src/fixate/reporting/csv.py @@ -84,6 +84,7 @@ Test End