Last Updated: October 26, 2023
Originally Published: March 18, 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
- Reading And Writing Single Inputs And Outputs Using Familiar digitalRead() And digitalWrite() Functionality
- Reading And Writing All Inputs Or Outputs Of A Port
- Utilizing Interrupts For Greater Efficiency
- Extending Interrupts With More Granularity
- Summary
Introduction
Sometimes, a project needs more digital I/O than what is available on your Arduino 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 Arduino development 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 Arduino platform. If you are new to Arduino, or would just like to refresh your knowledge, please see our Blink: Making An LED Blink On An Arduino Uno tutorial before proceeding with this one. In addition, this tutorial will use 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
- Arduino IDE
- Arduino Uno (R3 available on Arduino and SparkFun; WiFi Rev2 on Arduino and SparkFun) With Compatible USB Cable
- 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)
- 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 Arduino 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 an Arduino sketch 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.
My development system consists of the Arduino Uno WiFi Rev2 development board connected to a macOS based computer running the desktop Arduino IDE. If you are using a different Arduino board or computer setup, 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 Arduino 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 10 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.
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.
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 I2C clock, SCK (12), and data, SDA (13), pins of the MCP23017 IC are connected to the SCL and SDA pins respectively of the Arduino board to establish the I2C serial bus connection. Be aware that not all Arduino boards have the analog pins A4 and A5 connected to the I2C port. For this reason, I am using the SCL and SDA pins on the Digital row of the Arduino board.
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 D2 on the Arduino. 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 Arduino to your computer with the USB cable.
Reading And Writing Single Inputs And Outputs Using Familiar digitalRead() And digitalWrite() Functionality
This first approach implements the same calling mechanisms as Arduino’s standard digitalRead()
and digitalWrite()
functions. It should be very familiar to long time Arduino 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.
I researched various libraries available for use with the MCP23017 and chose the MCP23017 library by Bertrand Lemasle due to its thoroughness and ability to write to all MCP23017 registers if needed.
Open the Arduino IDE and install the MCP23017 library from within the Library Manager.
Next, create a sketch named PortExpander with the code shown below.
#include <MCP23017.h> #define MCP23017_I2C_ADDRESS 0x20 // I2C address of the MCP23017 IC const uint8_t LED0 = 0; // GPA0 (21) of the MCP23017 const uint8_t LED1 = 1; // GPA1 (22) of the MCP23017 const uint8_t LED2 = 2; // GPA2 (23) of the MCP23017 const uint8_t LED3 = 3; // GPA3 (24) of the MCP23017 const uint8_t LED4 = 4; // GPA4 (25) of the MCP23017 const uint8_t LED5 = 5; // GPA5 (26) of the MCP23017 const uint8_t LED6 = 6; // GPA6 (27) of the MCP23017 const uint8_t LED7 = 7; // GPA7 (28) of the MCP23017 const uint8_t Switch0 = 8; // GPB0 (1) of the MCP23017 const uint8_t Switch1 = 9; // GPB1 (2) of the MCP23017 const uint8_t Switch2 = 10; // GPB2 (3) of the MCP23017 const uint8_t Switch3 = 11; // GPB3 (4) of the MCP23017 const uint8_t Switch4 = 12; // GPB4 (5) of the MCP23017 const uint8_t Switch5 = 13; // GPB5 (6) of the MCP23017 const uint8_t Switch6 = 14; // GPB6 (7) of the MCP23017 const uint8_t Switch7 = 15; // GPB7 (8) of the MCP23017 MCP23017 mcp23017 = MCP23017(MCP23017_I2C_ADDRESS); // instance of the connected MCP23017 IC void setup() { Wire.begin(); // initialize I2C serial bus mcp23017.init(); // initialize MCP23017 IC // Configure MCP23017 I/O pins configurePinsWithPinMode(); // familiar pinMode() style // Reset MCP23017 ports mcp23017.writeRegister(MCP23017Register::GPIO_A, 0x00); mcp23017.writeRegister(MCP23017Register::GPIO_B, 0x00); } void loop() { readAndWriteWithDigitalReadAndDigitalWrite(); } void configurePinsWithPinMode() { // Configure output pins mcp23017.pinMode(LED0, OUTPUT); mcp23017.pinMode(LED1, OUTPUT); mcp23017.pinMode(LED2, OUTPUT); mcp23017.pinMode(LED3, OUTPUT); mcp23017.pinMode(LED4, OUTPUT); mcp23017.pinMode(LED5, OUTPUT); mcp23017.pinMode(LED6, OUTPUT); mcp23017.pinMode(LED7, OUTPUT); // Configure input pins with internal 100K pull-up resistors // Third argument inverts the polarity of the input value when read mcp23017.pinMode(Switch0, INPUT_PULLUP, true); mcp23017.pinMode(Switch1, INPUT_PULLUP, true); mcp23017.pinMode(Switch2, INPUT_PULLUP, true); mcp23017.pinMode(Switch3, INPUT_PULLUP, true); mcp23017.pinMode(Switch4, INPUT_PULLUP, true); mcp23017.pinMode(Switch5, INPUT_PULLUP, true); mcp23017.pinMode(Switch6, INPUT_PULLUP, true); mcp23017.pinMode(Switch7, INPUT_PULLUP, true); } void readAndWriteWithDigitalReadAndDigitalWrite() { // Read and write individual inputs and outputs mcp23017.digitalWrite(LED0, mcp23017.digitalRead(Switch0)); mcp23017.digitalWrite(LED1, mcp23017.digitalRead(Switch1)); mcp23017.digitalWrite(LED2, mcp23017.digitalRead(Switch2)); mcp23017.digitalWrite(LED3, mcp23017.digitalRead(Switch3)); mcp23017.digitalWrite(LED4, mcp23017.digitalRead(Switch4)); mcp23017.digitalWrite(LED5, mcp23017.digitalRead(Switch5)); mcp23017.digitalWrite(LED6, mcp23017.digitalRead(Switch6)); mcp23017.digitalWrite(LED7, mcp23017.digitalRead(Switch7)); }
Line 3 sets 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.
Lines 5-20 define the named references for our individual LEDs and switches. The integer values are how the MCP23017 library refers to the individual pins of PORTA (0
–7
) and PORTB (8
–15
). I have also included the specific MCP23017 port pins in the comments for correlation to the hardware.
Line 22 defines our instance, mcp23017
, of the MCP23017 chip.
In the setup()
routine, lines 25 and 26 initialize the I2C bus and the mcp23017
instance.
Lines 32-33 reset the ports by writing zeros directly to the MCP23017’s GPIO registers. This helps the sketch and the MCP23017 IC remain synced and is especially helpful between uploads.
Since we will be covering multiple configuration and operational approaches, I separated them into their own distinct functions.
The first configuration approach is the configurePinsWithPinMode()
function that is called in setup()
. It uses the familiar pinMode()
functionality to configure the individual inputs and outputs of the MCP23017. Note the extra third argument, true
, used when setting the inputs. This optional argument inverts the polarity of the value read from the input pin. In other words, if the real value is LOW
, it will give you HIGH
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 LOW
when turned on, but it would be nice to show them as HIGH
instead. Enabling the polarity inversion option of the MCP23017 does just that.
Likewise, the readAndWriteWithDigitalReadAndDigitalWrite()
function is the first operational approach and is called in the loop()
routine. It simply reads the current value of an input (switch) and writes that value to the associated output (LED). Since this routine is run continuously, any flipped switches are immediately reflected in the LEDs.
Save, compile, and upload the sketch to give it a try. Turn on and off a few of the switches and watch the matching LEDs turn on and off.
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 the end of your sketch.
void configurePinsWithPortMode() { // Configure PORTA (LEDs) mcp23017.portMode(MCP23017Port::A, 0b00000000); // direction (IODIRA) - set direction of all pins as outputs // Configure PORTB (switches) mcp23017.portMode(MCP23017Port::B, 0b11111111, // direction (IODIRB) - set direction of all pins as inputs 0b11111111, // pull-up (GPPUB) - enable 100K pull-up resistors on all inputs 0b11111111); // polarity (IPOLB) - invert logic polarity for all inputs } void portCopy() { // Copy values from PORTB (switches) to PORTA (LEDs) mcp23017.writePort(MCP23017Port::A, mcp23017.readPort(MCP23017Port::B)); }
Then replace the pins configuration line within the setup()
function from
configurePinsWithPinMode(); // familiar pinMode() style
to
// configurePinsWithPinMode(); // familiar pinMode() style configurePinsWithPortMode(); // concise portMode() style
thereby commenting out the old pins configuration routine and adding the new one underneath the original.
Likewise, replace the body of the loop()
function with the following.
// readAndWriteWithDigitalReadAndDigitalWrite(); portCopy();
Notice that this approach drastically reduces the number of lines needed to configure the I/O pins thanks to the MCP23017 library’s portMode()
method. All pin directions for an entire port are written as a single byte with 0
representing an output and 1
an input for each pin. The optional third and fourth arguments of portMode()
, used for PORTB, enable the pull-up resistor and polarity inversion 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 portCopy()
function consists only of a single statement that simply reads all the pins of PORTB and writes their values to the pins of PORTA.
Save your work and upload the updated sketch to test the code. It should function in exactly the same way as before, 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 digitalRead()
, digitalWrite()
, readPort()
, and writePort()
methods of the MCP23017 library are analogous to the isrDigitalRead()
, osrDigitalWrite()
, isrReadRegister()
, and osrWriteRegister()
functions we created and 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:
changeOutputsWithDigitalWrite()
,changeOutputsWithBinaryValues()
,changeOutputsWithDefinedNames()
,changeOutputsWithBitOperations()
, andcycleLEDs()
and Part 2 – The 74HC165:
readInputsWithDigitalRead()
,readInputsWithBinaryValues()
,readInputsWithDefinedNamesAndBitOperations()
, andreadAndPrintInputsOnChange()
also apply to the MCP23017 when replacing the relevant operational functions of the shift registers with their MCP23017 library counterparts. So instead of reiterating those approaches, we are focusing on the differences and capabilities of the MCP23017 IC in general and the MCP23017 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 lines to the PortExpander sketch just below the I/O Pin Definitions
and above the creation of the mcp23017
instance towards the top of the sketch.
const uint8_t MCP23017_INTB = 2; // connected to MCP23017 INTB (19) pin volatile bool switchDidChange = false; // change status of MCP23017 input pins
INTB is the interrupt output pin that will be driven LOW
by the MCP23017 when it detects a change in the PORTB input pins. The switchDidChange
flag will be set by our interrupt service routine (ISR). The volatile
keyword is used since the variable can be changed at any time by the ISR.
Next, add the following functions to the end of the PortExpander sketch.
void configureInterrupts() { // Configure MCP23017 interrupts mcp23017.interruptMode(MCP23017InterruptMode::Separated); // INTA and INTB act independently mcp23017.interrupt(MCP23017Port::B, CHANGE); // trigger an interrupt when an input pin CHANGE is detected on PORTB // Set up interrupt connection and attach interrupt service routine mcp23017.clearInterrupts(); // reset interrupt system pinMode(MCP23017_INTB, INPUT_PULLUP); // utilize microprocessor's internal pull-up resistor attachInterrupt(digitalPinToInterrupt(MCP23017_INTB), mcp23017ChangeDetectedOnPortB, FALLING); // INTB is active LOW }
This function configures the MCP23017 interrupt system and attaches an interrupt service routine.
The interrupt mode is set to Separated
to allow the INTA and INTB interrupt pins to act independently. An Or
mode option is available that internally ors the the pins together so that an interrupt for either PORTA or PORTB will be reflected on both pins simultaneously.
The interrupt()
method enables interrupts for all pins of PORTB, detecting any CHANGE
that occurs. Other available options are FALLING
and RISING
.
The clearInterrupts()
method resets the interrupt system by clearing any previous interrupts and initiates watching for further pin changes.
The mcp23017ChangeDetectedOnPortB()
ISR is then attached for execution when the Arduino detects that the MCP23017_INTB
pin is FALLING
.
void mcp23017ChangeDetectedOnPortB() { switchDidChange = true; }
This is the interrupt service routine that simply sets the switchDidChange
flag when executed.
void readAndWritePortOnInputChange() { if (switchDidChange) { delay(100); // allow time for MCP23017 to set interrupt registers portCopy(); // copy values from PORTB (switches) to PORTA (LEDs) mcp23017.clearInterrupts(); // clear interrupt switchDidChange = false; } }
This function checks if the switchDidChange
flag was set by the ISR, and if so, copies the values from PORTB (inputs) to PORTA (outputs). A small delay of 100 ms is used to ensure the MCP23017 has had enough time to update its internal interrupt registers. Once the copy is complete, the interrupts and flag are cleared.
Finally, we need to update the setup()
and loop()
functions to add the use of these new functions. Add the following line to the setup()
function between configuring the MCP23017’s I/O pins and resetting of its ports.
configureInterrupts();
Comment out the portCopy()
statement in the loop()
function and add the readAndWritePortOnInputChange()
to be executed instead. This latter function should now be the only thing being called within the loop()
function.
Save your work and upload the updated sketch 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 the end of the sketch
void readAndWritePinOnInputChange() { uint8_t flagA, flagB; // MCP23017 INTFA/B registers uint8_t capA, capB; // MCP23017 INTCAPA/B registers if (switchDidChange) { delay(100); // allow time for MCP23017 to set interrupt registers mcp23017.interruptedBy(flagA, flagB); // retrieve pin causing interrupt mcp23017.clearInterrupts(capA, capB); // clear interrupt and capture pin states at time of interrupt uint8_t pin = 0; // input pin causing interrupt for (pin = 0; pin < 8; pin++) { if (bitRead(flagB, pin)) break; } uint8_t value = bitRead(capB, pin); // new value of input pin causing interrupt mcp23017.digitalWrite(pin, value); // set appropriate LED with new value switchDidChange = false; } }
and then update the loop()
function to run this function instead.
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. The flagA
/flagB
and capA
/capB
variables hold these register values that are retrieved with the library’s interruptedBy()
method along with an overloaded version of the clearInterrupts()
method. 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 with digitalWrite()
. The PORTA interrupt registers are ignored since we are only interested in PORTB state changes at this time.
Save your work and upload the updated sketch to test the code. Again, it should function in exactly the same manner as before, but this time with more pin state granularity.
Before we end, I wanted to mention another very nice feature of the MCP23017 library is the ability to read from and write to any of the MCP23017 IC’s registers directly with the library’s readRegister()
and writeRegister()
methods. We saw an example of this in the setup()
function for resetting the GPIO ports. This capability provides maximum flexibility in configuring and accessing individual GPIO pin data independent of the library’s default functionality. Check out the library’s header file for a summary of the methods and general capabilities available with the MCP23017 library.
Now would be a good time to upload the BareMinimum sketch (Main Menu > File > Examples > 01.Basics > BareMinimum) to reset all pins back to their default states. This ensures no outputs are being driven when plugging in your board for your next project.
Summary
In this tutorial, we learned how to add both digital inputs and outputs to your Arduino board using the MCP23017 16-Bit I2C I/O Expander With Serial Interface IC. We covered multiple approaches for how to represent and control the MCP23017 IC’s GPIO in your Arduino code so that you can compare and choose the right implementation in your own designs. They range from a simplistic approach that copies all values of one port to another to using the familiar digitalRead()
and digitalWrite()
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 Doxygen 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.
Wow, this is exactly what I was looking for! All examples and options clearly explained. Thx John!
I’m very happy to hear that. Thank you so much for letting me know.
Great information! Attempted to add 2nd mcp23017 without success. I know it has to do with the instance but I cannot figure it out. I can get one or the other mcp23017 to work, but not both at the same time. Any advice would be awesome.
Added the following to your code to the digitalRead() & digitalWrite():
#define MCP23017_I2C_ADDRESS 0x21 // I2C address of the MCP23017 IC
const uint8_t LED8 = 0; // GPA0 (21) of the MCP23017
void setup() {
mcp23017.writeRegister(MCP23017Register::GPIO_A, 0x01);
mcp23017.writeRegister(MCP23017Register::GPIO_B, 0x01);
void loop() {
mcp23017.digitalWrite(LED8,HIGH);
delay(500);
mcp23017.digitalWrite(LED8,LOW);
delay(500);
void configurePinsWithPinMode() {
mcp23017.pinMode(LED8, OUTPUT);
Yes, two instances will be required to have two MCP23017 ICs operating simultaneously.
You first need to make sure the I2C address for the second IC is different from the first one. Tie the A0 pin of the second MCP23017 IC to 5V, instead of ground, so that its I2C address becomes 0x21.
Then add the following to the relevant sections of the initial PortExpander sketch. mcp23017 will continue referring to the original instance and we are defining mcp23017_2 to refer to the second instance.
Does that make sense?
Perfect Sense since it worked! Great information to learn from.
Thank you,
Dirk
You’re welcome. Glad it worked and thank you for reading.
Nice work, easy to understand and well laid out.
I’m fairly new to code but got 2 instances running. 16 switches, 16 LEDs. One chip does switches with interupts, one with LEDs.
I’ve been trying to get the outputs to latch or (toggle), Is there any fairly short way of doing that?
Any help would be appreciated.
Wow, that’s great!
The bitwise not (~) operator is used to flip, or toggle, bits in a number. You can toggle an individual output with
mcp23017.digitalWrite(LED0, ~mcp23017.digitalRead(LED0));
and use
mcp23017.write(~mcp23017.read());
to toggle all outputs.
Thanks for the swift reply John, Yes the (~) inverts the bit. Maybe I didn’t explain too well. I was looking for, Say; Press switch 4, LED 4 comes on, Press switch 4 again LED 4 goes off. Basically to control a bank of 16 Relays. I can obtain 2 bytes (Bank1 and Bank 2) but on releaseing buttons they revert to zero. Thanks again for the info.
If you are using dip or toggle switches, then your desired outcome happens naturally by just copying all the port values; turn the switch on and the LED turns on, turn the switch off and the LED turns off.
If you are wanting to use momentary push buttons, then a bit more work is required. You need an extra variable of type
uint16_t
that keeps track of the current state of all the 16 switches and you need to implement button debouncing. The code would probably look something like the following:I am using momentary push buttons with LED’s built in, and hardware de-bounce which seems to work well. (nothing strange happening yet!)
Thanks for your code, However I got in a bit of a knot with using both ports and 2 instances of the chip.
I discovered the ‘Exclusive Or’ Operator could do what I needed.
void portCopy() {
// Copy values from PORTA and PORTB (switches) to trigger1 and trigger2 (Temporary variables)
trigger1 =(mcp23017.readPort(MCP23017Port::A));
trigger2 =(mcp23017.readPort(MCP23017Port::B));
// Exclusive Or with the variable which holds the LED value and the Relay drive value
relay1 = (relay1^trigger1);
relay2 = (relay2^trigger2);
//Write the Relay drive value to the second instance of the chip. This keeps LED’s on until switch is //pressed again (and the Relays)
mcp23017_2.writePort(MCP23017Port::A,relay1);
mcp23017_2.writePort(MCP23017Port::B,relay2);
// Also can save a couple of (trigger) bytes by using this
void portCopy() {
relay1 = (relay1^mcp23017.readPort(MCP23017Port::A));
relay2 = (relay2^mcp23017.readPort(MCP23017Port::B)); mcp23017_2.writePort(MCP23017Port::A,relay1);
mcp23017_2.writePort(MCP23017Port::B,relay2);
}
I’m running this on an ESP32 (For the wifi) to have local and remote switching.
The handy thing with ESP32 is you can use interupt on any pin.
As my project advances I could send you the code, maybe use some of it on your Page or you might be able to tell me where I’m going wrong.
Again thanks for all the help and code.
Bri
You are very welcome. Glad to hear you got it working. Yes, please keep me updated on your progress and thanks for visiting our site.
Hi, I am also using momentary push buttons with LED’s built in and wanting to read and write on one pin so that LED state is lit. Is this how you are doing it, and can you explain to me how you have done this.
1) Read PIN button that is pushed
2) Write signal to current button LED ‘on’ state // turn off previous button LED
ola, posso usar este mesmo codigo no esp32?
I am not a Spanish speaker, so I may not have the correct translation.
I believe you can use the same sketch for an ESP32 board. You just need to install and use the correct core for the ESP32 and use the appropriate I2C pins.
I also found the following Getting started with ESP32 tutorial that may be helpful to you.
hello, first of all thank you for your sharing, your course is masterful, just a clarification, is the capacitor electrolytic or not, in the photo it looks like it is not, yet the link that sends on the page concerns electrolytic capacitors, in waiting for your reply, I wish you a good day
I’m happy to hear you liked the tutorial. No, the capacitor is not electrolytic, just a standard ceramic capacitor. The circuit will probably work just fine without it, but it does provide voltage stability in a noisy environment.
John, I had a previous comment about the project I’m working on. I just received the prototype board, and the VDD and VSS are flipped-flopped. The VSS has the +5VDC, and the VDD has the DGND.
The breadboard model from your code works, but the Altium designer switched from what I submitted. Is there a simple solution with code? Or pull out the soldering iron?
I see the VSS and VDD get used differently from my learning jpurney. Is there a reason for this?
Sorry to hear that, but these often end up being great learning experiences.
I’m afraid you are going to have to pull out the soldering iron. Except in rare instances, VDD is always connected to the higher voltage and VSS to the lower voltage (or ground).
bonjour, j’ai besoin d’aide pour notre projet d’un simulateur d’un réseau,
nous avons utiliser (un) Arduino uno et ( 14) MCP23017 pour contrôler des bouton poussoir.
dans l’installation il y a des MCP23017 qui sont même adresse mais l’un en écriture et l’autre en lecture
alors nous voudrions un peu d’aide pour le programme s’il vous plait.
It appears you are trying to connect 14 individual MCP23017 chips to the same I2C bus. The MCP23017 only has enough address pins for 8 separate chips.
You probably need something like the TCA9548A I2C Multiplexer or Adafruit PCA9548 8-Channel STEMMA QT / Qwiic I2C Multiplexer – TCA9548A Compatible breakout boards in order to let you share some of the I2C addresses.
merci beaucoup, mais j’aimerai posée une question s’il vous plait , est ce qu’on ne peut pas utiliser plus de 8 MCP23017 ?
You can, but you have to use multiplexers like I mentioned in my last comment.
et si tous les broche d’une MCP23017 sont en lecture ou en écriture , est ce qu’on devrais encore écrire cette code
mcp23017.writeRegister(MCP23017Register::GPIO_A, 0x00);
mcp23017.writeRegister(MCP23017Register::GPIO_B, 0x00);
Yes, those are needed to reset the 23017 on initial startup.
HELLO SIR , does this work if i use push button instead ? tq in advance
Yes, it should work just fine with pushbuttons. With normally open momentary pushbuttons, the values will be read as high when the buttons are being pressed and will be read as low when the buttons are released due to the polarity inversion implemented in the sketches. For the LEDs used in the tutorial, this will work just fine. If your application requires you to read “clean” button presses, you probably also need to perform button debouncing and keep track of the button states.
dricardo
20hpost #1
Hi everone. I’m in a deliberate dilemma of wanting to expand the ESP32’s digital ports and connect to the network via RJ45 cable.
First I installed the MCP23017 port expander including more than one (1) (Thus identifying the addresses) and it simply worked well with the example codes from the Adafruit-MCP23017 library itself.
Separately, I configured the ENC28J60 shield to connect the esp32 to the network using the cable. Luckily I also managed to get it working.
Finally I wanted to put together the codes so that I could control the inputs and outputs (Buttons and LEDs) and also through the home assistant via mqtt. I was only able to make the buttons and LEDs connected directly to the ESP32 work, I can control the LEDs through the buttons as well as through the home assistant via mqtt.
The problem now is to control the LEDs connected to the GPIO expander. They don’t trigger anything. Neither through the home assistant nor through the buttons. The buttons connected to the expanders also show no effect when pressed. I was supposed to see it through the serial monitor, in the same way I see those connected to the ESP32.
#include
#include
#include
#include
#include
#include
// Instanciar objeto mcp
Adafruit_MCP23X17 mcp1;
Adafruit_MCP23X17 mcp2;
// Definir o endereço MAC e IP para o ENC28J60
byte mac[] = {0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED};
IPAddress ip(192, 168, 1, 177);
// //Definir detalhes do MQTT broker
const char* mqtt_server = “192.168.1.29”;
const int mqtt_port = 1883;
const char* mqtt_username = “mqtt_user”;
const char* mqtt_password = “mqtt_pass”;
// Define GPIO pins
const int lampada_varanda = 2;
const int interruptor_varanda = 4;
const int interruptor_sala_de_estar = 0;
const int lampada_sala_de_estar = 0;
int lampada_varandaValue = LOW;
int lampada_sala_de_estarValue = LOW;
EthernetClient ethClient;
PubSubClient client(ethClient);
void callback(char* topic, byte* payload, unsigned int length) {
// lidar com a mensagem chegou
String content = “”;
char character;
for (int num = 0; num < length; num++) {
character = payload[num];
content.concat(character);
}
Serial.println(topic);
Serial.println(content); // Mensagens enviadas por ações de botõo são retornadas pelo broker e impressas no monitor serial
if (content == "1on") {
lampada_varandaValue = HIGH;
}
if (content == "1off") {
lampada_varandaValue = LOW;
}
if (content == "2on") {
lampada_sala_de_estarValue = HIGH;
}
if (content == "2off") {
lampada_sala_de_estarValue = LOW;
}
digitalWrite(lampada_varanda, lampada_varandaValue);
digitalWrite(lampada_sala_de_estar, lampada_sala_de_estarValue);
}
Bounce bouncer1 = Bounce();
Bounce bouncer2 = Bounce();
void setup() {
// Iniciar o Moitor Serial
Serial.begin(115200);
Wire.begin();
// Inicializar as instâncias dos MCP23017
if (!mcp1.begin_I2C(0x20)) {
Serial.println("Erro ao inicializar o MCP23017 1. Por favor, verifique as conexções.");
while (1);
}
if (!mcp2.begin_I2C(0x21)) {
Serial.println("Erro ao inicializar o MCP23017 2. Por favor, verifique as conexções.");
while (1);
}
// Setup Ethernet
Ethernet.begin(mac, ip);
// Setup MQTT
client.setServer(mqtt_server, mqtt_port);
client.setCallback(callback);
// Setup GPIO
pinMode(lampada_varanda, OUTPUT);
pinMode(interruptor_varanda, INPUT_PULLUP);
digitalWrite(interruptor_varanda, HIGH);
bouncer1.attach(interruptor_varanda);
bouncer1.interval(5);
mcp1.pinMode(interruptor_sala_de_estar, INPUT);
mcp2.pinMode(lampada_sala_de_estar, OUTPUT);
digitalWrite(interruptor_sala_de_estar, HIGH);
bouncer2.attach(interruptor_sala_de_estar);
bouncer2.interval(5);
}
void loop() {
// ConnectConectar ao MQTT
if (!client.connected()) {
reconnect();
}
//Lidando com as mensagens do mqtt broker de novo
client.loop();
if (bouncer1.update()) {
if (bouncer1.read() == HIGH) {
if (lampada_varandaValue == LOW) {
lampada_varandaValue = HIGH;
client.publish("casa/interruptores/varanda", "1on");
} else {
lampada_varandaValue = LOW;
client.publish("casa/interruptores/varanda", "1off");
}
}
}
int buttonState = mcp1.digitalRead(interruptor_sala_de_estar);
if (bouncer2.update()) {
if (bouncer2.read() == HIGH) {
if (lampada_sala_de_estarValue == LOW) {
lampada_sala_de_estarValue = HIGH;
client.publish("casa/interruptores/sala_estar", "2on");
} else {
lampada_sala_de_estarValue = LOW;
client.publish("casa/interruptores/sala_estar", "2off");
}
}
}
}
void reconnect() {
// Repetição até se conectar
while (!client.connected()) {
Serial.println("Tentando conectar ao servidor MQTT…");
// Tentativa de conecxão
if (client.connect("ESP32Client", mqtt_username, mqtt_password)) {
Serial.println("Conectado");
// Subcrevendo – se ao tópico (se Necessário)
client.subscribe("casa/interruptores/#");
} else {
Serial.print("Falha ao conectar, rc= ");
Serial.println(client.state());
Serial.println("Tentar de novo em 5 segundos");
// Esperar 5 segundos antes de tentar outra vez
delay(5000);
}
}
}
If anyone can take a look at my code and help me understand where I'm going wrong. It's a project to automate my home.
Hugs.
Dércio
Sorry, but I don’t have any experience with the Adafruit_MCP23X17 device library. Hopefully, someone else can offer assistance. Since this tutorial uses the MCP23017 library by Bertrand Lemasle, you might have better luck asking your question on one of Adafruit’s forums.
bonjour , j’ai besoin d’aide pour mon projet , j’utilise une cart arduino UNO multiplier par deux MCP23017 , l’autre MCP en lecture et l’autre en ecriture , mais il y a un problème de bibliothèque
I will try to help where I can. What problem are you having with the library?
These greatly helps me . But i have question please, I want to use it with blynk app as a smart home, the problem I found out is that, it has a limited distance, some meter away from the sensor, how can I extend the wires to about 200Metres . And was that possible?
I’m happy to hear that you found the tutorial beneficial.
Ideally, wire lengths of 200 m with sufficiently large wire widths should work just fine. For instance, the resistance of a 12 gauge wire at 200 m is only about 1 ohm. Realistically, it depends on how electrically “noisy” your environment is and how quickly you are flipping digital states. I don’t have much experience in this area, but I suggest you just try it and see how it goes. You can also try using a repeater or buffer circuit in the middle of the long wire run to boost the signal if necessary. If none of that works in your environment, a wireless solution may be the answer.
John, the journey continues. The hardware fix has been completed (see above comment), and I’m trying to find a solution as to why the code just stops, which seems to be happening during the inputs to the MCP23017.
I am not using interrupts since efficiency is not a priority at this moment. The MCP23017 is used for inputs only, my code minics your example expect my PORTA are inputs. I do have INTB to PIN13 of an Arduino2650, but I did not utilize INTA. I believe this can be internally connected per your explanation, correct?
The code works for the first few cycles, then as the inputs change state, it seems to stop in the MCP23017, and the Arduino2650 is not responsive to any inputs/outputs. Power cycle Arduino with the same results.
I’m confident this will be discovered as my error, but looking for guidance. The hardware is identical to your setup. Did I make a mistake by not using INTA? Are interrupts necessary for the code transmission on the I2C bus?
Thanks,
Dirk
I am a little confused. You stated you are not using interrupts, however, you mentioned INTB being connected to pin 13. You are correct that INTA and INTB can be internally ORed together using the
MCP23017InterruptMode::Or
mode. If you are not using interrupts, you don’t need to worry about the INTA or INTB pins. If you do end up using interrupts, I believe that pin 13 should work, however, it does have the built-in LED connected to that pin. I don’t think this would be a problem, but it may be an area of investigation.The use of interrupts is not required. The first sketch shown, within the Reading And Writing Single Inputs And Outputs Using Familiar digitalRead() And digitalWrite() Functionality section, does not use interrupts. You can just poll the pins with the
mcp23017.digitalRead()
method. Try that code again, without the outputs, and see if that works for you. If not, post your code somewhere and I will look at it for you.Please be aware that there is a hardware change in the latest MCP23017, starting at some point in 2022. The pins GPA7 and GPB7 are now output only. This holds only for the I2C version. In the SPI version (MCP23S17) the pins are still bi-directional.
Make sure to have the correct datasheet, dated 2022 or newer.
Wow, that changes things. It will definitely cause some consternation with existing designs.
Thank you so much for letting us know! I will make a note in the affected tutorials soon.
I could not find that a silicon change had occurred, so a datasheet only change of this magnitude was quite disturbing. Upon further research, I found that Microchip discovered an issue with the part and updated the datasheet accordingly. Please see this article by Microchip for additional information.
Just to adapt the original code to be Implemented at ESP01
#define GPIO0 0 // for GPIO 0 as SDA pin
#define GPIO2 2 // for GPIO 2 as SCL pin
void setup() {
Wire.begin(GPIO0, GPIO2); // wake up I2C bus SDA, SCL // initialize I2C serial bus
…
… /* Do not set pinmode for GPIO0 and GPIO2 */
}
Thanks Leandro.
Thanks for great tutorial.
mcp23017.portMode(MCP23017Port::B,
0b11111111, // direction (IODIRB) – set direction of all pins as inputs
0b11111111, // pull-up (GPPUB) – enable 100K pull-up resistors on all inputs
0b11111111); // polarity (IPOLB) – invert logic polarity for all inputs
I have buttons, then I press it shorted to ground, than button is released, its pullup to 5v
if I set 0b00000000 polarity it will be sense the same – meaning push ->low, release high, its correct ?
You are welcome. I’m glad you liked it.
Your assumption is correct. When all pins are inverted (
0b11111111
), pressing the button (closing the switch) drives the pinLOW
but is read asHIGH
. When all pins are not inverted (0b00000000
), pressing the button drives the pinLOW
and is read asLOW
.Your tutorial has been very helpful as I am new to coding. I am using the mcp23017 to detect up to 16 switches which will ultimately be used to position servos. I have used your last method to detect which switch changed and its current state. Generally the code works but I get random errors with the wrong switch indicated.
I have disconnected the switches on GPA7 & GPB7 as suggested without improvement and suspect I need also to set the direction of these pins to output, but cannot fathom how to set individual pins.
Can you advise if I’m making the correct diagnosis and advise how to set the direction of GPA7 & GPB7
I’m glad to hear that you are finding the tutorial helpful.
The easiest way to set an individual pin as an output is to use the
pinMode()
method as was used in the sketch’sconfigurePinsWithPinMode()
function.Since you will be using a combination of inputs and outputs within the same port, it is probably more understandable to use the
configurePinsWithPinMode()
function than theconfigurePinsWithPortMode()
function to set all of the individual pins.When debugging issues, it is usually best to start with a known good state and progress from there, slowly making and testing changes until you have what you want. With that in mind, did the full functionality of the tutorial work for you before you began making changes? If so, I suggest ignoring interrupts for now and start by using the
configurePinsWithPinMode()
function to make all of the pins an input, except for the GPA7 and GPB7 pins, and then create a new function, that gets called from within theloop()
function, that reads all of the pin values and prints them to the Serial Monitor for you to check their status.If you want me to look at your code for suggestions, please post it somewhere and send me a link to it.
Thanks for responding so promptly. I have done as you suggest and used pin mode to set the pins and not using 7 and 15 for input. it seems as though pins 7 & 15 were causing the erratic results previously.
I would still prefer to use interrupts to detect which switch has changed as it seems the most efficient method, so I will persevere as my original code did compile and I think that it was the 7 & 15 issue causing the problem .
I will change the port mode method of setting pin direction to pin mode and see if this cures the problem. unfortunately I have to go abroad for a few weeks so my have to put this on one side for now. Many thanks for you help.
You are very welcome.
Sorry if I was not clear before. I was trying to suggest that we begin with a simpler sketch to make sure the fundamentals are working before including additional complexity, such as interrupts. I completely understand wanting to have that functionality. We can probably even simplify the interrupt and pin reading code since you are only interested in inputs.
Enjoy your trip and we can revisit it once you return.
Hi
I have two MCP23017 in my system
Both configured as
mcp_1.portMode(MCP23017Port::A, 0b11111111, 0b11111111, 0b00000000);
mcp_1.portMode(MCP23017Port::B, 0b11111111, 0b11111111, 0b00000000);
mcp_2.portMode(MCP23017Port::A, 0b11111111, 0b11111111, 0b00000000);
mcp_2.portMode(MCP23017Port::B, 0b11111111, 0b11111111, 0b00000000);
both MCP’s on different addresses.
To MCP23017 1 I have 8 switches connected, when switch is on, it pulls down through 3.7k resistor
On portA bits PA0 to PA6 the behavior as needed, but the PA7 gives me strange result
Then I connect to port B, same behavior occurs: PB0 to PB6 is correct, PB7 strange results.
The second MCP23017 works correctly on every bit
Do you have any advice?
Thanks
Hi
After long research, pins GPA7, GPB7 are output only, according Microchip MCP23017 Datasheet Rev D. Please be aware. So only 14 inputs can be used.
Thanks
Thank you for the heads up and I am sorry to hear you had to do so much research. Perhaps you are looking at an older version of the tutorial as I had already included a warning, in October of 2023, at the top of the tutorial about this issue.
Hello
I was so happy to find such good tutorial, it help me a lot, I did not notice to this warning on top of the page.
Again – Thank you for great tutorial
You are very welcome.
Hello John
I have picked up the trail once more and done as you suggested (see post January 31 2024).
I now have 14 switches working using your final method with interrupts. Success!….. However I need to add a second MCP.
I have created a second instance MCP23017_2 and added the lines of code outlined as you suggested to Dirk N (November 13 2021)
I am struggling to work out how to modify the interrupt routine to include the extra MCP.
Any advice you can offer would be much appreciated
Regards Craig
Hi Craig, welcome home. Glad to hear you generally have the second MCP23017 instance working.
To keep things simple, instead of modifying the existing interrupt routine, you could just duplicate the
mcp23017ChangeDetectedOnPortB()
andreadAndWritePinOnInputChange()
functions and modify them to use themcp23017_2
instance with the Arduino D3 pin used for the second instance’s interrupt pin. A separate ISR flag will also need to be added and don’t forget to either duplicate or add the second instance to theconfigureInterrupts()
function.Once all of that is working, you could try combining the routines by passing in a reference to a specific mcp23017 instance.
Hope that helps.
Hello John, Many thanks for your prompt reply. I have tried to follow your suggestions to add a second instance but I have got lost somewhere and I cannot see where. The first (MCP23017) works as expected but the second (MCP23017_2) is totally erratic sometimes giving the correct result at other times giving a string of results and at other not responding at all.
I have attached a link to the sketch and wonder if you can see where I’ve gone wrong.
Best wishes Craig
LINK REMOVED
I reviewed your sketch and overall, it looks good.
I suggest you configure the pin mode for GPA7 of the mcp23017_2 instance to make sure you aren’t being affected by the GPA7 pin issue.
I also see that you configured interrupts for Port A of the mcp23017_2 instance but did not set the pin modes for those pins. I suggest configuring those pins as well for completeness’ sake.
If that fixes things, you can try to drop the pin mode and interrupt configuration for the extra port but I would still keep the GPA7 pin mode setting for that port.
Hi, Great news I have set the pin modes for all pins as you suggested and I now get expected results consistently.
Strangely on startup of the Arduino and the two MCP23017 the serial monitor prints the number 121 twice, then waits for a switch to change.
I don’t think this will matter?
Many thanks for your assistance I can now add the code to move my servos.
Best wishes Craig
That is great news and you are very welcome.
For the strange “121” prints, try adding the following below your
Serial.begin(9600);
lineor move all three lines to the end of the
setup()
routine. If that works, try it without the delay.Hi, I am using momentary switches with built-in LED’s and want to read and write on one pin dynamically so that the pushed button sends voltage to the LED to maintain ‘on’ state until another button is pressed. I have read this is possible, could you explain to me how this is done. Thanks.
1 ) Read PIN button that is pushed
2) Turn on LED for current ‘on’ state pin // Turn off previous button LED pin
Hi Justin, thanks for reading.
If I understand the question correctly, you want to read and control a momentary pushbutton with an integrated LED using a single GPIO pin.
While this is technically possible, it requires multiplexing the GPIO pin to both input and output states very quickly so that your eyes do not detect that the LED is off while the button is being read in the input state. Generally, this involves 1) setting the GPIO pin as an input, 2) reading the button’s state, 3) setting the GPIO pin as an output, 4) writing the LED’s state, and 5) continuously repeating the process.
While this approach is doable with a small number of GPIO pins on the Arduino board directly, I think trying to implement it with a large number of GPIO pins on the MCP23017 over an I2C serial bus while also having to take into account button debouncing and keeping track of button states would become too complicated very quickly and cause too much LED flicker to be usable.
I believe you are probably better off using extra MCP23017 pins to control the LED states.
Sorry I could not be more helpful. If you do figure it out, please let us know.