Electronics Software Development

Adding Digital I/O To Your CircuitPython Compatible Board: Part 3 – The MCP23017

More I/O Graphic
Written by John Woolsey

Skill Level: Intermediate

Table Of Contents

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.

What Is Needed

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.

Schematic Diagram Of An MCP23017 Digital I/O Circuit Connected To A CircuitPython Compatible Board
Schematic Diagram Of An MCP23017 Digital I/O Circuit Connected To A CircuitPython Compatible Board

Eight LEDs are connected to PORTA‘s GPA0GPA7 (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 GPB0GPB7 (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, A0A2 (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.

Completed MCP23017 Digital I/O Circuit Connected To A Feather M4 Express Board
Completed MCP23017 Digital I/O Circuit Connected To A Feather M4 Express Board

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 A0A2 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(), and
  • cycle_leds()

and Part 2 – The 74HC165:

  • read_single_inputs(),
  • read_inputs_with_binary_values(),
  • read_inputs_with_defined_names(), and
  • read_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

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.

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.

About the author

John Woolsey

John is an electrical engineer who loves science, math, and technology and teaching it to others even more.
 
He knew he wanted to work with electronics from an early age, building his first robot when he was in 8th grade. His first computer was a Timex/Sinclair 2068 followed by the Tandy 1000 TL (aka really old stuff).
 
He put himself through college (The University of Texas at Austin) by working at Motorola where he worked for many years afterward in the Semiconductor Products Sector in Research and Development.
 
John started developing mobile app software in 2010 for himself and for other companies. He has also taught programming to kids for summer school and enjoyed years of judging kids science projects at the Austin Energy Regional Science Festival.
 
Electronics, software, and teaching all culminate in his new venture to learn, make, and teach others via the Woolsey Workshop website.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.