EDIT NOVEMBER 2021…
Anyone interested in controlling multiple relays may also want to take a look at this topic, which uses a pre-built 16-way relay board and 2x 74hc595 shift registers:
END OF EDIT
.
I had a project recently where I wanted to use 5 physical buttons, with built-in LED indicators, to control 5 relays. That meant having 15 GPIOs available, so I built the project around an ESP32 dev board. I also needed a few more GPIO pins for other sensors, so by the time I was done I was on the verge or running out of pins.
A post about the MCP23017 IO Expansion Board caught my attention and when I investigated these a bit further, I decided to order a couple of the Waveshare MCP23017 boards. These cost around $4 each.
The boards use I2C to give an additional 16 pins, and multiple boards can be daisy-chained together if needed. Up to 8 boards can be daisy-chained in this way to give up to 128 additional GPIOs!
I started-off using one board on its own, then moved-on to daisy-chaining a second board once I’d learned a bit more about how they work and some of their quirks.
This first post concentrates on describing a bit about the boards when operated individually, then in the second post I discuss what I found when I daisy-chained two boards together.
The Waveshare MCP23017 hardware
The boards can operate on 3.3 or 5v logic levels and are just a bit larger than a Wemos D1 Mini.
They have a socket at one end, with a lead that has Dupont connectors on it which makes it easy to connect to your MCU, and plated-through holes at the other end to allow you to solder in a connector (which isn’t provided) to allow another board to be daisy-chained off of the first one. I used right angle pins when soldered into these connectors, but regular header pins would also work fine.
There are two rows of 0.1” pitch pins on each side and these are labelled PA0-PA7 and PB0-PB7, plus GND and VCC pins.
The test scenario
The scenario I’m going to cover in this mini tutorial is very simple – having eight physical pushbutton switches connected to one side of the expander board (PA0-PA7), and an 8-way active LOW relay board connected to the other side (PB0-PB7).
Pushing one of these physical switches will toggle the corresponding relay. I’ve also added 8 switch widgets connected to virtual pins in the Blynk app and these will mirror the state of the relays and allow the relays to be controlled individually from the app.
When the NoderMCU connects/reconnects to Blynk the state of the relays will be synchronised with the switch widget settings in the app via the BLYNK_CONNECTED()
callback function.
The expander board will be connected to a NodeMCU board using the standard SCL (D1) and SDA (D2) pins and powered from the NodeMCU. The relay board is powered separately but shares a common ground with the NodeMCU/mcp expander.
I chose to use the Adafruit MCP23017 library. This appears to be the same as the one provided by Waveshare (with the exception that the library name and the mcp object used in the code are prefixed with “Adafruit_”). Neither of the libraries and the documentation are great, although I guess that the guys from Adafruit may be a bit more proactive about developing theirs in future.
Here’s a link to the library:
I’m using version 1.0.6 (you can find the version number in the library.properties file).
Interrupts
I wanted to use interrupts to detect when one of the physical buttons on the mcp expander is pressed, as scanning a large number of buttons in sequence would be fairly impractical. The facility to use interrupts was one of the things that attracted me to these MCP23017 boards, along with the I2C interface, so that’s where I started playing around.
The theory is that one pin of the NodeMCU is used to detect an interrupt that occurs on any of the MCP23017 boards, then the MCP23017 is interrogated to find out which pin, on which expander board was responsible for triggering the interrupt.
Unfortunately, I couldn’t get any of the interrupt code examples to work, even after updating them to use the ICACHE_RAM_ATTR attribute on the ISR callback function. The examples were also very badly written (certainly from a Blynk friendliness point of view) as all the processing is done in the void loop()
once an interrupt is detected.
After stripping the code back to the bare essentials I managed to make some headway, then discovered the first quirk of using these boards – when an interrupt is detected it needs to be cleared before additional interrupts can be handled. This is hinted-at in the documentation and the code examples, but the method used to clear the interrupt isn’t compatible with the NodeMCU instruction set.
It turns-out that the way to clear the interrupt is simply to perform an mcp.digitalRead
on the pin that triggered the interrupt.
The second quirk is that if you don’t successfully clear the interrupt register using the mcp.digitalRead()
command then the only way of clearing it is to remove the power from the MCP23017 board.
Simply resetting the NodeMCU or re-flashing it with updated code will not clear the MCP23017’s interrupt register. As I have the expander boards powered from my NodeMCU, it was a case of pulling the power from the NodeMCU to clear the MCP23017’s interrupt register, and re-starting the IDE’s serial monitor to be able to see debug messages.
I thought I had this problem solved by putting an mcp.digitalRead()
command in my interrupt service routine, but I found that I was still having issues. It turned-out that I was stopping interrupts on the NodeMCU while the ISR executed, but this didn’t stop interrupts occurring on the expander board, which weren’t being detected and therefore not being cleared.
Eventually I added a function called clear_mcp_interrupts()
which loops through all of the expander board’s pins that have interrupts attached and does an mcp.digitalRead()
on each one in turn. This seemed to be quite a clunky solution, but it worked well and doesn’t seem to slow-down the responsiveness of the boards (at least with two boards and 16 interrupts in total).
I then added a call to this clear_mcp_interrupts()
function in void setup()
so that all interrupts can be reset simply by rebooting the NodeMCU.
As I said earlier, the 16 GPIO pins on the MCP23017 are arranged in two banks, one on each side of the board. They are labelled PA0 to PA7 for bank A and PB0 to PB7 for bank B.
There are two interrupt output lines that could be used to connect to pins on the NodeMCU, one for each bank of pins and these are labelled INTA and INTB. .However, there is a way to tie these together in the code, so that an interrupt generated by any of the pins on either bank will trigger both the INTA and INTB outputs, so that only one of these interrupt lines needs to be used.
There doesn’t seem to be any reason to use the two separate interrupt lines, and some other makes of MCP23017 boards only appear have one of these available, so I’m a bit confused about why we would need two of them.
I’m sticking with using just one (it doesn’t matter which) and initialising the mcp.setupInterrupts()
command in a way that ties both banks together.
Interrupts can be defined as RISING, FALLING or CHANGE as usual, although there seems to be a relationship with this and the way that the mcp.setupInterrupts()
command works that I haven’t quite gotten to grips with yet (but that doesn’t matter for this example)
When an interrupt triggers you can find out which pin was responsible by using the mcp.getLastInterruptPin()
command.
You can also get the value of the pin using the mcp.getLastInterruptPinValue()
command. This isn’t much use when using RISING (in which the value will always be 1) or FALLING (in which the value will always be 0), but it may be useful when using CHANGE interrupts.
As I’m only using FALLING interrupts in this sketch the mcp.getLastInterruptPinValue()
command doesn’t make an appearance and I haven’t explored anything other than FALLING interrupts so far
Now, here’s the bit I don’t understand yet…
The mcp.setupInterrupts()
command has three parameters. These are Mirroring, OpenDrain and Polarity.
Mirroring is simple – if it’s set to true
then the INTA and INTB interrupt lines are tied together, they mirror each other.
The documentation for the other two parameters is very confusing (for me at least) and here’s what I’ve found in the comments of the examples and in the libraries:
OpenDrain says that if set to false
the INTA/B will not be floating and the library says “will set the INT pin to value [presumably if false
] or open drain [presumably if true
]”. I have no idea what this means in practice, as the only way that I’ve managed to get FALLING interrupts to work is with this set to true
, so that’s the setting I’m using.
Polarity if set to LOW then interrupts will be signalled with a LOW
This presumably means that when set to LOW the INTA or INTB lines will go from HIGH to LOW when an interrupt is detected on any of the interrupt pins.
As I’m using INTA connected to GPIO14 on my NodeMCU and have a FALLING interrupt attached to GPIO14 to tell me when an interrupt has occurred from one of the expander pins, leaving this set to LOW is as far as I’ve gone with this setting.
I’m therefore using the following command in my sketch, with FALLING interrupts:
mcp.setupInterrupts(true,false,LOW);
So INTA/B are mirrored, INTA/B are not floating and an interrupt is signalled with a LOW signal.
I guess if you use anything other than FALLING interrupts on either your expander pins or your NodeMCU then you may need to re-visit this bit of the code.
I2C addresses
By default, the Waveshare board uses 0x27 as the I2C address. This can be changed by adding solder bridges to pads labelled A0, A1 and A2 on the top of the board. If you are daisy-chaining multiple MCP23017 boards together then each board must have a unique address.
In the same way, if you are using other devices or sensors on your I2C bus then either the address of the MCP23017 or the other device(s) may need to be changed to avoid a conflict. In the code example below for a single expander board I’m using the default address of 0x27 (none of the contacts at A0, A1 or A2 are bridged).
Below is a table of how the I2c address can be changed by bridging different combinations of the A0, A1 and A2 pads…
Addr(BIN) Addr(hex) ID A2 A1 A0
0100111 0x27 7 0 0 0 <--- Default for the WaveShare Board
0100110 0x26 6 0 0 1
0100101 0x25 5 0 1 0
0100100 0x24 4 0 1 1
0100011 0x23 3 1 0 0
0100010 0x22 2 1 0 1
0100001 0x21 1 1 1 0
0100000 0x20 0 1 1 1
Initialising the expander pins
Pins are initialised with an mcp.pinMode
statement in a very similar way to how you’d initialise a regular GPIO pin in your void setup()
They can also be pulled-up (although the syntax for this is a little odd – see below), and defined as having an interrupt attached to them (the syntax is ‘setup’ as opposed to the ‘attach’ that you’d use with a regular GPIO pin).
Here’s why I find the pullUp syntax a little odd…
This command turns on the internal 10k pullup resistor:
mcp.pullUp(mcp_pin_PA3, HIGH);
Presumably, mcp.pullUp(mcp_pin_PA3, LOW);
has the opposite effect pull it LOW – I’ve not tested this.
Either way, I’d have preferred to see opposing pullUP and pullDOWN commands, or at least seen true or false as the pullUp parameters rather than HIGH or LOW – but maybe I’m just being picky.
Wiring it all up
NodeMCU Expansion Board A
3v3 VCC (Red wire)
GND GND (Black Wire)
D2 (GPIO4) SDA (Blue wire)
D1 (GPIO5) SCL (Yellow wire)
D5 (GPIO14) INTA (Orange wire
Not Connected INTB (Green wire)
Eight physical buttons are connected to pins PA0 to PA7 and the other side of the buttons are connected to GND on the expansion board
Relay Board Expansion Board A
In1 PB0
In2 PB1
In3 PB2
In4 PB3
In5 PB4
In6 PB5
In7 PB6
In8 PB7
GND GND
Relay board VCC and GND are connected to a separate 3.3v supply
Eight Blynk button widgets in push mode are connected to virtual pins V0 to V7.
Enough talking, let’s see the code
This code originally looked a bit different, but when I started daisy-chaining the boards together I learned a few things that lead me to change the single-board code to make it easier to adapt to working with multiple boards daisy-chained together.
In the syntax example I’ve used above, I’ve used the mcp
object, but actually you have to define a separate object for each board in the daisy-chain. To make things slightly less confusing, I’ve used mcp_A, mcp_B etc. as the object names. In this single board example I’m just using mcp_A as the object name.
#define BLYNK_PRINT Serial
#include <ESP8266WiFi.h>
#include <BlynkSimpleEsp8266.h>
#include <Wire.h>
#include "Adafruit_MCP23017.h" // https://github.com/adafruit/Adafruit-MCP23017-Arduino-Library
char auth[] = "REDACTED";
char ssid[] = "REDACTED";
char pass[] = "REDACTED";
// Connect pin SCL of the expander (mcp_A) to SCL (D1)
// Connect pin SDA of the expander (mcp_A) to SDA (D2)
// Connect pin INTA or INTB of the expander (mcp_A) to ESP8266 pin D5 (GPIO14)
// don't solder A0,A1,A2 (default ID of 7) on the expander (mcp_A)
// Connect 8 physical momentary push buttons to mcp_A pins 0 to 7 inclusive (labelled PA0 to PA7), with the other side of the button connected to GND
// Connect an 8-way Active LOW relay board to mcp_A pins 8 to 15 inclusive (labelled PB0 to PB7 inclusive)
// Attach Blynk button widgets (in Switch mode) to virtual pins 0 to 7 inclusive. These mirror the physical momentary buttons of mcp_A pins PA0 to PA7
/*
The begin() param can be 0 to 7, the default param is 7 (no solder bridges on A0, A1 & A2) giving an address of 0x27.
Addr(BIN) Addr(hex) ID A2 A1 A0
010 0111 0x27 7 0 0 0 <--- Default for WaveShare Board
010 0110 0x26 6 0 0 1
010 0101 0x25 5 0 1 0
010 0100 0x24 4 0 1 1
010 0011 0x23 3 1 0 0
010 0010 0x22 2 1 0 1
010 0001 0x21 1 1 1 0
010 0000 0x20 0 1 1 1
*/
// Create the mcp object
Adafruit_MCP23017 mcp_A;
// Define the interrupt pin on the MCU
int esp_interrupt_pin=14; // GPIO14 (D5)
volatile unsigned long last_interrupt; // Used to store the time in millis of the last interrupt, for debouncing
int debounce_delay = 100; // The minimum delay in milliseconds between button presses, for debouncing
volatile uint8_t pin_num; // when an interrupt is detected and decoded the pin which generated the interrupt is stored here
// Define friendly names for the pins...
// These mcp_A pins will have physical buttons connected to them, with the other side connected to ground.
// the pins will be pulled-up when we declare them later...
byte mcp_A_pin_PA0=0;
byte mcp_A_pin_PA1=1;
byte mcp_A_pin_PA2=2;
byte mcp_A_pin_PA3=3;
byte mcp_A_pin_PA4=4;
byte mcp_A_pin_PA5=5;
byte mcp_A_pin_PA6=6;
byte mcp_A_pin_PA7=7;
// These mcp_A pins will be connected to an 8-way relay board, which is Active LOW
// so the relays activate when the pin is pulled LOW.
// These pins will also be pulled-up and initialised as HIGH when we declare them later...
byte mcp_A_pin_PB0=8;
byte mcp_A_pin_PB1=9;
byte mcp_A_pin_PB2=10;
byte mcp_A_pin_PB3=11;
byte mcp_A_pin_PB4=12;
byte mcp_A_pin_PB5=13;
byte mcp_A_pin_PB6=14;
byte mcp_A_pin_PB7=15;
// Interrupt handler for pins mcb_A pins 0-7
void ICACHE_RAM_ATTR intCallBack()
{
noInterrupts();
pin_num = mcp_A.getLastInterruptPin();
if(millis()-last_interrupt>=debounce_delay) // Debounce routine
{
// for debugging only...
Serial.println("_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-");
Serial.println("A button has been pressed!!!");
Serial.print("Pin = ");
Serial.println(pin_num);
Serial.println("_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-");
Serial.println();
process_button_press(); // Call the function that will activate the correct relay etc.
}
last_interrupt=millis();
interrupts();
clear_mcp_interrupts(); // Call the function that clears all of the interrupt registers
}
void process_button_press()
{
int current_pin_state = mcp_A.digitalRead(pin_num + 8); // Get the current state of the pin
mcp_A.digitalWrite(pin_num + 8, !current_pin_state); // Write the opposite state to the pin to toggle it
// This code assumes that we have Blynk button widgets (in Switch mode) on virtual pins that are numbered the same
// as the ones used for the physical buttons (V0 = MCP Pin 0, V1 = MCP Pin 1 etc)
// As the relays are Active LOW then we need to write the opposite state to the Blynk switch widget than we do to the MCP pin...
// Relay = Off (HIGH or 1) then Blynk button widget = 0
// Relay = On (LOW or 0) then Blynk button widget = 1
Blynk.virtualWrite(pin_num, current_pin_state);
}
BLYNK_WRITE_DEFAULT()
{
int widget_pin = request.pin; // Which virtual pin triggered this BLYNK_WRITE_DEFAULT callback?
int widget_value = param.asInt(); // Get the value from the virtual pin (O = off, 1 = on)
mcp_A.digitalWrite(widget_pin + 8, !widget_value); // Write the opposite state to the pin on mcp_A to toggle the relay
}
void setup(){
Serial.begin(74880);
pinMode(esp_interrupt_pin,INPUT); // Initialise the MCU pin used for the Interrupt line (INTA or INTB on the Waveshare board)
mcp_A.begin(7); // default address, no solder pads bridged
// We mirror INTA and INTB, so that only one line is required between MCP and Arduino for int reporting
// The INTA/B will not be Floating
// INTs will be signalled with a LOW
mcp_A.setupInterrupts(true,false,LOW);
// In this next section I could have used the friendly names (0-15) defined in the declarations, but chose to stick with the
// names screen-printed on the mcp board to aid wiring and debugging.
// In the following blocks of code we declare each of our pins that will be used for interrupts (Pins 0-7) on mcb_A
// If other pins have interrupts assigned to them then they must be added to the clear_mcp_interrupts() function
// configuration for a button PA0 on mcb_A
// interrupt will trigger when the pin is taken to ground by a pushbutton
mcp_A.pinMode(mcp_A_pin_PA0, INPUT);
mcp_A.pullUp(mcp_A_pin_PA0, HIGH); // turn on a 100K pullup internally
mcp_A.setupInterruptPin(mcp_A_pin_PA0,FALLING);
// configuration for a button PA1 on mcb_A
// interrupt will trigger when the pin is taken to ground by a pushbutton
mcp_A.pinMode(mcp_A_pin_PA1, INPUT);
mcp_A.pullUp(mcp_A_pin_PA1, HIGH); // turn on a 100K pullup internally
mcp_A.setupInterruptPin(mcp_A_pin_PA1,FALLING);
// configuration for a button PA2 on mcb_A
// interrupt will trigger when the pin is taken to ground by a pushbutton
mcp_A.pinMode(mcp_A_pin_PA2, INPUT);
mcp_A.pullUp(mcp_A_pin_PA2, HIGH); // turn on a 100K pullup internally
mcp_A.setupInterruptPin(mcp_A_pin_PA2,FALLING);
// configuration for a button PA3 on mcb_A
// interrupt will trigger when the pin is taken to ground by a pushbutton
mcp_A.pinMode(mcp_A_pin_PA3, INPUT);
mcp_A.pullUp(mcp_A_pin_PA3, HIGH); // turn on a 100K pullup internally
mcp_A.setupInterruptPin(mcp_A_pin_PA3,FALLING);
// configuration for a button PA4 on mcb_A
// interrupt will trigger when the pin is taken to ground by a pushbutton
mcp_A.pinMode(mcp_A_pin_PA4, INPUT);
mcp_A.pullUp(mcp_A_pin_PA4, HIGH); // turn on a 100K pullup internally
mcp_A.setupInterruptPin(mcp_A_pin_PA4,FALLING);
// configuration for a button PA5 on mcb_A
// interrupt will trigger when the pin is taken to ground by a pushbutton
mcp_A.pinMode(mcp_A_pin_PA5, INPUT);
mcp_A.pullUp(mcp_A_pin_PA5, HIGH); // turn on a 100K pullup internally
mcp_A.setupInterruptPin(mcp_A_pin_PA5,FALLING);
// configuration for a button PA6 on mcb_A
// interrupt will trigger when the pin is taken to ground by a pushbutton
mcp_A.pinMode(mcp_A_pin_PA6, INPUT);
mcp_A.pullUp(mcp_A_pin_PA6, HIGH); // turn on a 100K pullup internally
mcp_A.setupInterruptPin(mcp_A_pin_PA6,FALLING);
// configuration for a button PA7 on mcb_A
// interrupt will trigger when the pin is taken to ground by a pushbutton
mcp_A.pinMode(mcp_A_pin_PA7, INPUT);
mcp_A.pullUp(mcp_A_pin_PA7, HIGH); // turn on a 100K pullup internally
mcp_A.setupInterruptPin(mcp_A_pin_PA7,FALLING);
// In the following blocks of code we declare each of our pins that will be used to control the relays (Pins 8-15) on mcb_A
// configuration for a button PB0 on mcb_A
mcp_A.pinMode(mcp_A_pin_PB0, OUTPUT);
mcp_A.pullUp(mcp_A_pin_PB0, HIGH); // turn on a 100K pullup internally
mcp_A.digitalWrite(mcp_A_pin_PB0, HIGH);
// configuration for a button PB1 on mcb_A
mcp_A.pinMode(mcp_A_pin_PB1, OUTPUT);
mcp_A.pullUp(mcp_A_pin_PB1, HIGH); // turn on a 100K pullup internally
mcp_A.digitalWrite(mcp_A_pin_PB1, HIGH);
// configuration for a button PB2 on mcb_A
mcp_A.pinMode(mcp_A_pin_PB2, OUTPUT);
mcp_A.pullUp(mcp_A_pin_PB2, HIGH); // turn on a 100K pullup internally
mcp_A.digitalWrite(mcp_A_pin_PB2, HIGH);
// configuration for a button PB3 on mcb_A
mcp_A.pinMode(mcp_A_pin_PB3, OUTPUT);
mcp_A.pullUp(mcp_A_pin_PB3, HIGH); // turn on a 100K pullup internally
mcp_A.digitalWrite(mcp_A_pin_PB3, HIGH);
// configuration for a button PB4 on mcb_A
mcp_A.pinMode(mcp_A_pin_PB4, OUTPUT);
mcp_A.pullUp(mcp_A_pin_PB4, HIGH); // turn on a 100K pullup internally
mcp_A.digitalWrite(mcp_A_pin_PB4, HIGH);
// configuration for a button PB5 on mcb_A
mcp_A.pinMode(mcp_A_pin_PB5, OUTPUT);
mcp_A.pullUp(mcp_A_pin_PB5, HIGH); // turn on a 100K pullup internally
mcp_A.digitalWrite(mcp_A_pin_PB5, HIGH);
// configuration for a button PB6 on mcb_A
mcp_A.pinMode(mcp_A_pin_PB6, OUTPUT);
mcp_A.pullUp(mcp_A_pin_PB6, HIGH); // turn on a 100K pullup internally
mcp_A.digitalWrite(mcp_A_pin_PB6, HIGH);
// configuration for a button PB7 on mcb_A
mcp_A.pinMode(mcp_A_pin_PB7, OUTPUT);
mcp_A.pullUp(mcp_A_pin_PB7, HIGH); // turn on a 100K pullup internally
mcp_A.digitalWrite(mcp_A_pin_PB7, HIGH);
clear_mcp_interrupts(); // Call the function that clears all of the interrupt registers
Blynk.begin(auth, ssid, pass);
// Here we attach an interrupt to the MCU pin that will be used to listen for interrupts from the mcp_A...
attachInterrupt(digitalPinToInterrupt(esp_interrupt_pin),intCallBack,FALLING);
}
void clear_mcp_interrupts()
{
for(int loop=0;loop<=7;loop++) // Loop through the 8 pins on mcp_A that have interrupts attached
{
mcp_A.digitalRead(loop); // Read the pin to clear the interrupt register for that pin
}
// If interrupts are assigned to other pins then they must be added to this function
}
BLYNK_CONNECTED()
{
// This code runs when the MCU connects or re-connects to the Blynk server
// We want to get the latest widget switch values and update the relays so that they match this
// Loop through pins 0-7 and do a Blynk.syncVirtual
// If more pins are used that relate to additional mcps, or different patterns of virtual pins are used on the existing mcps
// the this for loop range will need to be adjusted...
for(int loop=0;loop<=7;loop++)
{
Blynk.syncVirtual(loop); // This forces the BLYNK_WRITE_DEFAULT callback function to trigger for each virtual pin in turn
}
}
void loop()
{
Blynk.run();
}
The next post will cover using multiple MCP23017 boards daisy-chained together…
Pete.