Last Updated: October 26, 2023
Originally Published: July 28, 2021
Skill Level: Intermediate
WARNING:
Microchip discovered an issue with the MCP23017 chip, where under certain circumstances, the I2C SDA signal may become corrupted when the chip’s GPA7 and/or GPB7 pins are used as inputs. For this reason, they updated the datasheet to reflect that those pins should only be used as outputs.
This tutorial utilizes the MCP23017 GPB7 pin as an input. If you see strange or unexpected behavior while following this tutorial, you may want to adjust your circuit and code accordingly.
I am not planning to change the tutorial since, hopefully, a silicon change is in the works, but I at least want to notify readers of the potential issue.
Thank you to Günter Nowinski for notifying me about this issue.
Table Of Contents
- Introduction
- What Is Needed
- Background Information
- Building The Circuit
- Installing The Library
- Reading And Writing Single Inputs And Outputs Using Familiar Pin Reading And Setting Functionality
- Reading And Writing All Inputs Or Outputs Of A Port
- Utilizing Interrupts For Greater Efficiency
- Extending Interrupts With More Granularity
- Additional Resources
- Summary
Introduction
Sometimes, a project needs more digital I/O than what is available on your development board. This often happens when you connect to components that require a lot of pins for their interface, e.g. some displays, or your project uses many discrete sensors and/or actuators.
This three-part tutorial teaches you how to add more digital inputs and outputs to your CircuitPython compatible board. Each part focuses on a specific integrated circuit (IC) chip.
Part 1 – The 74HC595 described how to add digital outputs using the 74HC595 8-bit serial-in parallel-out (SIPO) shift register IC.
Part 2 – The 74HC165 described how to add digital inputs using the 74HC165 8-bit parallel-in serial-out (PISO) shift register IC.
Part 3 – The MCP23017 (currently reading) describes how to add both digital inputs and outputs using the MCP23017 16-Bit I2C I/O Expander With Serial Interface IC.
A basic understanding of electronics and programming is expected along with some familiarity with the CircuitPython ecosystem. If you are new to CircuitPython, or would just like to refresh your knowledge, please see our Getting Started With CircuitPython On Compatible Microcontroller Boards or Getting Started With CircuitPython On Raspberry Pi With Blinka tutorials before proceeding with this one.
This tutorial uses a solderless breadboard to build a circuit from a schematic diagram. The All About Circuit’s Understanding Schematics, SparkFun’s How to Read a Schematic, Core Electronics’ How to Use Breadboards, and Science Buddies’ How to Use a Breadboard guides are good resources for learning how to translate a schematic to a breadboard.
The resources created for this tutorial are available on GitHub for your reference.
This tutorial is provided as a free service to our valued readers. Please help us continue this endeavor by considering a GitHub sponsorship or a PayPal donation.
What Is Needed
- Linux, macOS, Or Windows Based Computer With A USB Port
- Mu Python Editor (recommended)
- Either A CircuitPython Compatible Microcontroller Board With Compatible USB Cable (available on Adafruit) Or A Raspberry Pi Running Raspberry Pi OS Or Similar Linux Based OS (available on Raspberry Pi Foundation and Adafruit)
- Solderless Breadboard (available on Adafruit and SparkFun)
- Preformed Breadboard Jumper Wire Kit (available on SparkFun and CanaKit)
- 5 x Male/Male Jumper Wires (available on Adafruit and Arrow)
- MCP23017 16-Bit I2C I/O Expander With Serial Interface IC (available on Adafruit and Digi-Key)
- 8 x Standard 5mm LEDs (available on Adafruit and SparkFun)
- 8-Position Dip Switch (available on SparkFun and Digi-Key)
- 0.1 µF Ceramic Capacitor (available on SparkFun and Jameco)
- 2 x 4.7 KΩ Resistors (available on SparkFun and Amazon)
- 8 x 330 Ω Resistors (available on SparkFun and Amazon)
Background Information
If you need to add both digital inputs and outputs to your project, the MCP23017 IC from Microchip is a good choice to incorporate into your design. It is a 16-bit I/O port expander that adds a total of 16 additional digital GPIO pins, in two ports, PORTA and PORTB with 8 pins each, that communicates with your CircuitPython compatible board over an I2C serial interface. 8-bit and SPI port expander versions are also available if you prefer.
Each MCP23017 IC can be set to one of eight I2C addresses (0x20-0x27) giving you a total of 128 (8 ICs x 16 pins) additional digital I/O pins that you can add to your project.
Each GPIO pin of the MCP23017 can be configured as either an input or an output. Additional options can also be enabled for each pin, i.e., an internal pull-up resistor, triggering an interrupt, and polarity inversion. Interrupts can even be configured to not only trigger on a change but also trigger on a comparison to a default value set by the user.
The MCP23017 is highly configurable. I will be covering many, but not all, of its capabilities. To learn more, check out the MCP23017 datasheet.
There are multiple ways to configure and use the MCP23017 within a CircuitPython program to access and control GPIO pins. Each has its pros and cons and can be heavily dependent on the nature of the additional inputs or outputs required. For instance, are the additional I/O highly disparate and need to be manipulated separately, or are they more homogeneous and can be referred to as a single block? I will present four different approaches in this tutorial, all producing the same basic functionality, so that you can easily compare among them and choose the best approach for your own design. While all of these approaches eventually update the MCP23017’s output pins (LEDs) with associated changes in the input pins (switches), each approach provides a different way to visualize or manipulate individual pins.
I am using Adafruit’s Feather M4 Express microcontroller board connected to a macOS based computer with the Mu Python editor for this tutorial. I also verified the CircuitPython program created in this tutorial works on a Raspberry Pi 3 Model B running the Raspberry Pi OS operating system using the Blinka library for CircuitPython support. If you are using a different CircuitPython compatible board, computer setup, or code editor, the vast majority of this tutorial should still apply, however, some minor changes may be necessary.
If you need assistance with your particular setup, post a question in the comments section below and I, or someone else, can try to help you.
Building The Circuit
Before connecting any circuitry to your CircuitPython compatible board, disconnect it from power and your computer. This avoids accidental damage during wiring.
Place the components and wire up the circuit on a breadboard according to the schematic diagram shown below.
Eight LEDs are connected to PORTA‘s GPA0–GPA7 (21-28) pins of the MCP23017 IC via 330 Ω resistors constituting the 8 digital outputs being added to the system. Please note, the MCP23017 has a maximum sink/source current capability per I/O pin of 25 mA and a total chip current capability of 125 mA into the VDD pin or 150 mA out of the VSS pin. Our use of 8 LEDs, with expected currents of 4 mA or less each, easily falls within the proper current range, but additional design constraints may need to be considered when using more outputs or higher current devices.
An 8-position dip switch is connected to PORTB‘s GPB0–GPB7 (1-8) pins of the MCP23017 constituting the 8 digital inputs being added to the system. Eight individual standard single-pole single-throw (SPST) switches may be used instead of the integrated 8-position dip switch if you prefer. Typically, we would also need to incorporate either pull-up or pull-down resistors in series with the 8 switches. However, we will be utilizing the internal pull-up resistors of the MCP23017 so that we can eliminate the need for those external resistors. Since pull-up resistors are being used, the other side of the switches are all tied to ground.
A 0.1 µF bypass capacitor was placed across the power pins of the MCP23017 IC in order to reduce any power supply noise that may be present.
The MCP23017’s three address pins, A0–A2 (15-17), set the lowest three bits of the I2C address range (0x20-0x27) that will be used to communicate to the IC over the I2C serial bus. Pins tied low (ground) correspond to a value of 0 and pins tied high (power) correspond to a value of 1. All address pins must be tied to either power or ground and can not be left floating. Since we are tying all address pins to ground (000), this correlates to an address of 0x20 for our MCP23017 IC. If you prefer to use a different I2C address or plan to use more than one MCP23017 IC in your design, make sure to tie the address pins for each IC appropriately.
The MCP23017’s RESET (18) pin is an active low input and must be externally biased so that it is not left floating. I tied it directly to power since we do not plan to reset the chip directly.
The I2C clock, SCK (12), and data, SDA (13), pins of the MCP23017 IC are connected to the SCL and SDA pins respectively of your CircuitPython compatible board to establish the I2C serial bus connection. Please note the use of the required 4.7 KΩ pull-up resistors on the I2C lines.
INTA (20) and INTB (19) are the interrupt output pins for PORTA and PORTB respectively of the MCP23017. Later, we will be utilizing interrupts on PORTB to detect switch changes, so INTB is connected to D5 of your CircuitPython compatible board. We will not be using interrupts with PORTA so I left the INTA pin unconnected.
The circuit should look similar to the one shown below once completed.
Once the circuit is built, connect your general CircuitPython compatible microcontroller board to your computer with the USB cable. For a Raspberry Pi, connect the power and boot it up.
Installing The Library
Before installing any libraries, make sure you are running the latest stable release of CircuitPython on your compatible microcontroller board or the Blinka library on a Raspberry Pi.
$ pip3 install --upgrade Adafruit-Blinka
We will take advantage of the existing Adafruit_CircuitPython_MCP230xx driver library to communicate with the MCP23017 IC in our circuit. On a general CircuitPython compatible microcontroller board, copy the adafruit_mcp230xx directory from the latest stable bundle to the lib directory of the board’s CIRCUITPY drive. On a Raspberry Pi, install the library with the following command.
$ pip3 install adafruit-circuitpython-mcp230xx
Reading And Writing Single Inputs And Outputs Using Familiar Pin Reading And Setting Functionality
This first approach implements the standard CircuitPython pin reading and setting mechanisms by reading or changing the value
attribute of a single pin. It should be very familiar to most CircuitPython users and the easiest to understand. However, when reading and writing multiple inputs or outputs, it will involve more I2C communication than other approaches since only one input or output at a time can be read or written respectively.
Open Mu or your favorite code editor and create a CircuitPython program with the code shown below.
import board from digitalio import DigitalInOut, Direction, Pull from adafruit_mcp230xx.mcp23017 import MCP23017 MCP23017_I2C_ADDRESS = 0x20 leds = [] switches = [] mcp23017 = MCP23017(board.I2C(), address=MCP23017_I2C_ADDRESS) def configure_pins(): # LEDs - MCP23017 Port A (pins 0-7) for pin in range(0, 8): leds.append(mcp23017.get_pin(pin)) for led in leds: led.direction = Direction.OUTPUT led.value = False # Switches - MCP23017 Port B (pins 8-15) for pin in range(8, 16): switches.append(mcp23017.get_pin(pin)) for switch in switches: switch.direction = Direction.INPUT switch.pull = Pull.UP switch.invert_polarity = True def read_and_write_pin(): for pin, switch in enumerate(switches): if switch.value: leds[pin].value = True else: leds[pin].value = False # Main configure_pins() while True: read_and_write_pin()
Lines 7-8 define our leds
and switches
lists that will hold the MCP23017 pin references for our individual LEDs and switches.
Line 10 defines our instance, mcp23017
, of the MCP23017 chip and requires a reference to the board’s I2C port. The last argument, MCP23017_I2C_ADDRESS
, defined on line 5, is optional and specifies the address we will use to communicate with the MCP23017 IC over the I2C bus. This address needs to match the address specified by the hardwired A0–A2 pins discussed in the Building The Circuit section. The library’s default address value is 0x20 if not specified.
Since we will be covering multiple configuration and operational approaches, I separated them into their own distinct functions.
The first configuration approach is the configure_pins()
function. It uses the familiar CircuitPython pin configuration mechanisms to configure the individual inputs (switches) and outputs (LEDs) of the MCP23017. But before doing so, it builds the leds
and switches
lists using the library’s get_pin()
method to retrieve references to the MCP23017’s I/O pins.
Note the use of the library’s invert_polarity
attribute on line 26. This feature of the MCP23017 allows you to invert the polarity of the value read from the input pin. In other words, if the real value is False
, it will give you True
when read, and vice versa. This ability comes in quite handy since we had to tie the common sides of the dip switches to ground in order to take advantage of the internal pull-up resistors of the MCP23017 IC. With that being the case, the switches become False
when turned on, but it would be nice to show them as True
instead. Enabling the polarity inversion option of the MCP23017 does just that.
Instead of explicitly setting each individual pin’s attributes, the alternative CircuitPython switch_to_output()
and switch_to_input()
methods may be used instead in order to set all attributes in a single call. If you would like to try these, replace all the attribute settings for the LEDs and switches with the following.
for led in leds: led.switch_to_output(value=False)
for switch in switches: switch.switch_to_input(pull=Pull.UP, invert_polarity=True)
The read_and_write_pin()
function is the first operational approach. It simply enumerates through the leds
and switches
lists reading the current value of an input (switch) and writing that value to the associated output (LED). Since this routine runs continuously (being the only thing included in the While True
loop of the main section), any flipped switches are immediately reflected in the LEDs.
Now that our circuit is built and our software is written, let’s run and test our program.
On a general CircuitPython compatible microcontroller board, save the program as code.py to the top level of the board’s CIRCUITPY drive. It will begin running automatically.
On a Raspberry Pi, save the program as port_expander.py and then run the program.
$ python3 port_expander.py
Turn on and off a few of the switches and watch the matching LEDs turn on and off in response.
On a Raspberry Pi, press CTRL-C to exit the program when you are done.
If there is something that needs further explanation, please let me know in the comment section and I will try to answer your question.
Reading And Writing All Inputs Or Outputs Of A Port
This second approach takes advantage of the fact that we designed PORTA to be all outputs and PORTB to be all inputs giving us the ability to not only easily manage all pins of a port, but spend less time in I2C bus communication. It produces the most concise code, but does not indicate the meaning of each of the inputs or outputs.
Add the following functions to your program, just before the main section.
def configure_ports(): mcp23017.iodira = 0b00000000 # set all port A pins (LEDs) as outputs mcp23017.iodirb = 0b11111111 # set all port B pins (switches) as inputs mcp23017.gppub = 0b11111111 # enable pull-ups on all port B pins (switches) mcp23017.ipolb = 0b11111111 # invert polarity on all port B pins (switches) def port_copy(): mcp23017.gpioa = mcp23017.gpiob
Then, comment out the call to the configure_pins()
function within the main section and add a call to the new configure_ports()
function so that we’re calling the newly added function instead of the first one. Likewise, comment out the call to the read_and_write_pin()
function in the endless loop and add a call to the new port_copy()
function. The updated main section should now look like the following.
# Main # configure_pins() configure_ports() while True: # read_and_write_pin() port_copy()
The configure_ports()
function directly sets the individual port configuration registers of the MCP23017 IC. All pin directions for an entire port (iodira
and iodirb
) are written as a single byte with 0
representing an output and 1
an input for each pin. Similarly, the pull-up (gppub
) and polarity (ipolb
) registers are configured with 1
(enabled) and 0
(disabled) for each pin. All bits in each byte of this configuration are all of the same value, but that does not have to be the case. You can mix and match inputs, outputs, and enabled options for the various pins as you see fit. I deliberately designed the circuit and configured the ports in this manner so that I could take advantage of the fact that all pins of a given port are all of the same type. Thus, the port_copy()
function consists only of a single statement that simply reads all the pins of PORTB and writes their values to the pins of PORTA.
Notice that this approach not only drastically reduces the number of lines needed to configure, read, and write the MCP23017 I/O pins, but since operations are performed on entire ports instead of just the pins, I2C communication is drastically reduced as well. The only drawback is that the meaning of the individual pins is not being conveyed.
It is possible to reduce the number of lines even further. We configured each port separately, but we could have instead set the configuration registers for both ports simultaneously using the following 16-bit operations. Choose which ever option you prefer; they should function the same.
mcp23017.iodir = 0xFF00 # set port A pins (LEDs) as outputs and port B pins (switches) as inputs mcp23017.gppu = 0xFF00 # enable pull-ups on port B pins (switches) only mcp23017.ipol = 0xFF00 # invert polarity on port B pins (switches) only
Save and run your program to test the code. The port approach should function in exactly the same way as the pin based approach, just with less code.
Port based reading and writing are good choices to use when all the pins of a port are the same type (inputs or outputs) and can be referred to as a single block, like a bank of LEDs or switches in our case.
As you can probably tell from our work so far, the reading and writing of pins and ports for the MCP23017 are analogous to those used in Part 1 – The 74HC595 and Part 2 – The 74HC165 of this series. Hence, all of the approaches and examples we covered in Part 1 – The 74HC595:
change_single_outputs()
,change_outputs_with_binary_values()
,change_outputs_with_defined_names()
, andcycle_leds()
and Part 2 – The 74HC165:
read_single_inputs()
,read_inputs_with_binary_values()
,read_inputs_with_defined_names()
, andread_and_print_inputs_on_change()
also apply to the MCP23017 when replacing the relevant operational functions of the shift registers with their Adafruit_CircuitPython_MCP230xx library counterparts. So instead of reiterating those approaches, we are focusing on the differences and capabilities of the MCP23017 IC in general and the Adafruit_CircuitPython_MCP230xx library in particular in this last part of the series.
Utilizing Interrupts For Greater Efficiency
The previous approaches continuously looped through reading and writing the I/O pins. This is not a very efficient use of the microcontroller’s time. We could use polling to read the current inputs and compare them with their last known values. That would be better, but still not that efficient. Fortunately, the MCP23017 chip is capable of triggering interrupts on input pin changes. Its interrupt system is quite extensive and we will cover many of its capabilities within these next two sections.
The approach covered in this section uses interrupts to alert us when an input has changed, thereby freeing up the microcontroller to perform other tasks as necessary until an interrupt is triggered. This approach can be quite useful when the inputs are not expected to change very often.
Add the following line just below the imports
section at the top of the program to define our interrupt pin.
mcp23017_intb = DigitalInOut(board.D5)
INTB is the interrupt output pin that will be driven low by the MCP23017 when it detects a change in the PORTB input pins.
Next, add the following function just below the existing configure_ports()
function.
def configure_interrupts(): mcp23017_intb.direction = Direction.INPUT mcp23017_intb.pull = Pull.UP mcp23017.interrupt_enable = 0xFF00 # enable interrupts on port B pins (switches) only mcp23017.interrupt_configuration = 0x0000 # compare pins against previous values mcp23017.clear_ints() # clear all interrupts
This function configures the MCP23017 interrupt system along with reading the associated INTB pin.
The library’s interrupt_enable
attribute, set on line 4, is used to set which of the MCP23017’s input pins are enabled for interrupts. Here, we are only enabling interrupts on port B bins, the higher order byte of the 16-bit number.
The interrupt_configuration
attribute, set on line 5, is used to tell the MCP23017 whether each pin is compared against a specific default value (defined as 1
) or against the pin’s previous value (defined as 0
). Since we are using the latter option, all pins are set to 0
. If you would prefer to compare against default values, you can use the library’s default_value
attribute to set the default values and then update the interrupt_configuration
attribute appropriately with 1
‘s for each pin to enable.
The clear_ints()
method resets the interrupt system by clearing all previous interrupts and initiates watching for further pin changes.
Next, add the following function below the port_copy()
function.
def read_and_write_port_on_input_change(): if not mcp23017_intb.value: # active low port_copy() # copy port B (switches) values to port A (LEDs) mcp23017.clear_ints() # clear all interrupts
This function checks if the mcp23017_intb
pin was driven low by the MCP23017, and if so, copies the values from PORTB (inputs) to PORTA (outputs). Once the copy is complete, the interrupts are cleared.
NOTE: Since CircuitPython does not currently support true GPIO interrupts with their associated interrupt handlers, this modified implementation just polls the MCP23017’s interrupt pin waiting for an active low state. True interrupt handling may be achieved on some small board computers that use the Blinka library for CircuitPython support. For example, interrupts can be properly handled on a Raspberry Pi using both the Blinka and RPi.GPIO libraries. Please see the Adafruit_CircuitPython_MCP230xx library’s mcp230xx_event_detect_interrupt.py example program for details.
Finally, we need to update the main section to used these new functions.
# Main # configure_pins() configure_ports() configure_interrupts() while True: # read_and_write_pin() # port_copy() read_and_write_port_on_input_change()
Save and run the updated program to test the code. It should function in exactly the same manner as before, but with much greater efficiency and far less I2C communication.
Extending Interrupts With More Granularity
This last approach utilizes more of the MCP23017 interrupt system’s capabilities to provide greater detail of specific pin states at the time an interrupt occurred.
Add the following function to your program, just before the main section.
def read_and_write_pin_on_input_change(): if not mcp23017_intb.value: # active low flagb = mcp23017.int_flagb # retrieves which pin(s) caused the interrupt capb = mcp23017.int_capb # retrieves pin values captured at time of interrupt for pin in flagb: leds[pin - 8].value = capb[pin - 8] # set LED output value to captured switch input value mcp23017.clear_ints() # clear all interrupts
The MCP23017’s INTFA and INTFB registers flag the pins that caused the interrupt for PORTA and PORTB respectively. The INTCAPA and INTCAPB registers capture all the pin values at the time the interrupt was triggered. These values may be retrieved with the library’s int_flaga
/int_flagb
and int_capa
/int_capb
attributes. These register values are then used to retrieve the specific pin that was changed, along with its new value, and then update only the associated output pin. The PORTA interrupt registers are ignored since we are only interested in PORTB state changes at this time.
Now, update the main section to use the new function and revert back to the original configure_pins()
configuration as well so that we have access to the leds
list.
# Main configure_pins() # configure_ports() configure_interrupts() while True: # read_and_write_pin() # port_copy() # read_and_write_port_on_input_change() read_and_write_pin_on_input_change()
Save and run the updated program to test the code. Again, it should function in exactly the same manner as before, but this time with more pin state granularity.
Note, I did not use any switch debouncing techniques in this design for simplicity’s sake. The read_and_write_pin_on_input_change()
function used in this approach can be extra sensitive to debouncing issues since we are only addressing specific pin changes. This may cause some of the LEDs to not turn on or off as expected if your switch transitions are particularly slow. Try flipping the switches as quickly as you can to help alleviate some of those debouncing issues.
Although I have covered a lot of the MCP23017 I/O Expander IC and Adafruit_CircuitPython_MCP230xx library’s capabilities, please make sure to review the chip’s datasheet and library’s Documentation for additional features available.
Additional Resources
- MCP23017 Datasheet
- Adafruit_CircuitPython_MCP230xx Driver Library Documentation and Repository
- Adafruit Using MCP23008 & MCP23017 with CircuitPython Learning Guide
- Microchip MCP23XX I/O Port Expander Family
Summary
In this tutorial, we learned how to add both digital inputs and outputs to your CircuitPython compatible board using the MCP23017 16-Bit I2C I/O Expander With Serial Interface IC. I presented multiple approaches for how to represent and control the MCP23017 IC’s GPIO in your CircuitPython code so that you can compare and choose the right implementation for your own designs. They range from a simplistic approach that copies all values of one port to another to using CircuitPython’s familiar pin reading and writing mechanisms. We even covered how to use interrupts to allow for greater efficiency of the microcontroller’s time.
The final source code and schematic used for this tutorial are available on GitHub. The GitHub version of the code is fully commented to include additional information, such as the program’s description, circuit connections, code clarifications, and other details. The comments are also Sphinx compatible in case you want to generate the code documentation.
As a reminder, this tutorial was provided as a free service to our valued readers. Please help us continue this endeavor by considering a GitHub sponsorship or a PayPal donation.
Thank you for joining me along this journey and I hope you enjoyed the experience. Please feel free to share your thoughts or questions in the comments section below.
[…] Adding Digital I/O To Your CircuitPython Compatible Board: Part 3 – The MCP23017 – Woolsey Workshop. […]
Im trying to make a macro pad using a Magtag as the MCU. To be able to do other things on the magtag i was thinking of using the check for interrupts-aproach.
Il have the magtag connected to the mcp23017 through a 4 cable stemmaQT connector
il have a Seesaw rotary encoder connected via i2c (stemmaQT) to change profiles (1 macrobutton does a new thing with every profile)
And the key presses will be done over a 3 row, 6 col. Neokey plate.
Issues im having is trying to follow this guide. It seems straight forward, i copy the imports from the top bit and then choose the next aproach, right?
Im getting a bit confused with what to choose and how to mix the different codes i need to run.
Doesnt get better with my MU telling me that there is an attribute error: The object “module” doesnt have any attribute ‘D5’
Can i offload the handling of LED’s to the Neokey code/library?
I would be happy to try and help, but I’m not quite sure I understand what you are asking.
In general, it sounds like you have a MagTag controller that you want to use to control a rotary encoder and a NeoKey keypad, both through the I2C STEMMA QT port. In addition, you are looking to also control an MCP23017 to have extra digital I/O, also controlled through the I2C STEMMA QT port. Is that correct?
Both the rotary encoder and the NeoKey keypad should be able to be controlled via the adafruit-circuitpython-seesaw and adafruit-circuitpython-neokey libraries respectively. Please see the Adafruit I2C QT Rotary Encoder (https://learn.adafruit.com/adafruit-i2c-qt-rotary-encoder) and Adafruit NeoKey 1×4 QT I2C Breakout (https://learn.adafruit.com/neokey-1×4-qt-i2c) learning guides for additional information.
As for the MCP23017 part, which approach you choose is up to you on how you want to access the extra digital I/O. Since the MCP23017 access does not seem to be directly related to the other rotary encoder and a NeoKey keypad and functionality, I would suggest starting with the basic “Reading And Writing Single Inputs And Outputs” approach by itself first to make sure you have it working. Then try it using interrupts. I believe the D5 error you are seeing is due to the MagTag not having a D5 pin. It looks like the only user available GPIO pins for that device are A1 and D10 pins accessible through STEMMA 3 pin JST connectors. Try using the D10 pin for the interrupt pin. Once you have the MCP23017 working with the MagTag to your satisfaction, try adding back in the rotary encoder and a NeoKey keypad functionality.