How to use the I2C interfaces of the ESP32

About this post

In my last post, I reported on how you use the TCA9548A or simple MOSFETs to control multiple devices with the same I2C address. In this article, I’ll stick to the topic and show you how to use the I2C interfaces of the ESP32. You may also be able to avoid address conflicts this way. This article is not an introduction to the ESP32. I will catch up on this in a separate post. So, I assume you’ve already integrated the ESP32 into your development environment.

Another point that I am particularly concerned about in the article is the options of passing objects to functions or other objects.

I2C interfaces of the ESP32

There are a number of different ESP32 boards available. However, the I2C interfaces of the ESP32 are organized in the same way for all versions (that I am aware of!). The default interface is located at pins 21 (SDA) and 22 (SCL). It can also be assigned to other pins. The second interface is not predefined regarding the pins, i.e. you have to set them before you can use them.

The I2C interfaces of the ESP32 are located on pins 21 and 22 and / or on freely selectable pins.
The I2C interfaces of the ESP32 are located on pins 21 and 22 and / or on freely selectable pins.

How to use the standard I2C interface

If you only use the default I2C interface, there is no big difference in handling to the Arduino or ESP8266 boards (e.g. Wemos or ESP-01). In most cases, you will use a library for the controlled I2C part.

As an example for an I2C device, I use the A/D converter ADS1115again. You don’t have to deal with it in depth to understand the post. Just note that this module requires some initial settings and then converts voltages that are attached to the A0 port. No pull-ups are required for the circuit, since the ADS1115 already provides them.

ADS1115 attached to the standard I2C interface of the ESP32
ADS1115 attached to the standard I2C interface of the ESP32

I use my library ADS1115_WE. The ADS1115 object adc is created with ADS1115_WE adc = ADS1115_WE (ADS1115_I2C_ADDRESS). As usual, the I2C communication is initialized with Wire.begin().

#include<ADS1115_WE.h> 
#include<Wire.h>
#define ADS1115_I2C_ADDRESS  0x48

ADS1115_WE adc = ADS1115_WE(ADS1115_I2C_ADDRESS);

void setup() {
  Wire.begin();
  Serial.begin(9600); 
  setupAdc();
}

void loop() {
  float voltage = 0.0;
  
  voltage = adc.getResult_V();
  Serial.print("Voltage [V]: ");
  Serial.println(voltage);

  Serial.println("*********************************");  
  delay(1000);
}

void setupAdc(){
  if(!adc.init()){
      Serial.print("ADS1115 not connected!");
    }
    adc.setVoltageRange_mV(ADS1115_RANGE_6144);
    adc.setCompareChannels(ADS1115_COMP_0_GND);
    adc.setMeasureMode(ADS1115_CONTINUOUS); 
}

 

How to use the two I2C interfaces of the ESP32

Variant 1: Define two interfaces

If you want to use both I2C interfaces of the ESP32, you first have to choose two SDA and two SCL pins. They are freely selectable. My choice fell on pins 15, 16, 17 and 18. The wiring of two ADS1115 is not surprising:

Two ADS1115 attached to freely selected I2C pins
Two ADS1115 attached to freely selected I2C pins

For programming you need to know that Wire is an object of the TwoWire class. You don’t usually care, because the object Wire is created with the inclusion of Wire.h. In the first example, instead of wire, we use two self-created objects that we call I2C_1 and I2C_2:

TwoWire I2C_1 = TwoWire(0);
TwoWire I2C_2 = TwoWire(1); 

Before you say: Super, then I can create more TwoWire objects (TwoWire I2C_3 = TwoWire(2), etc.) according to this scheme, I have to disappoint you. That doesn’t work. Only 0 and 1 work.

Then you have to assign the SDA and SCL pins to I2C_1 and I2C_2. You do this by calling the begin() function. Optionally, you can pass the I2C frequency. This is what it looks like:

#define SDA_1 15
#define SCL_1 16
#define SDA_2 17
#define SCL_2 18
#define I2C_FREQ 400000
....
....
I2C_1.begin(SDA_1, SCL_1, I2C_FREQ);
I2C_2.begin(SDA_2, SCL_2, I2C_FREQ);

Now you have to assign the I2C interfaces to the objects of your I2C device (which is not supported by every library!). To do this you pass I2C_1 and I2C_2. Depending on the library, this usually has to be done when you initialize the object or with functions like begin() or init(). In most cases, example sketches of the libraries explain the procedure. For the ADS1115_WE library, passing the TwoWire object looks like this:

ADS1115_WE adc_1 = ADS1115_WE(&I2C_1, ADS1115_I2C_ADDRESS); 
ADS1115_WE adc_2 = ADS1115_WE(&I2C_2, ADS1115_I2C_ADDRESS);

A TwoWire object has a significant size. Therefore, to save memory space, most libraries do not work with local copies of the objects, but with the objects themselves. For this purpose, the TwoWire object is passed as a pointer, i.e. the receiving function uses pointers as parameters. In the function call I2C_1 and I2C_2 must be preceded by the address operator “&”. If you don’t have any experience with it, it’s certainly confusing at first. I will come back to that further down.

And this is what the complete sketch looks like:

#include<ADS1115_WE.h> 
#include<Wire.h>
#define ADS1115_I2C_ADDRESS  0x48
#define I2C_FREQ 400000

#define SDA_1 15
#define SCL_1 16
#define SDA_2 17
#define SCL_2 18

TwoWire I2C_1 = TwoWire(0);
TwoWire I2C_2 = TwoWire(1);

ADS1115_WE adc_1 = ADS1115_WE(&I2C_1, ADS1115_I2C_ADDRESS);
ADS1115_WE adc_2 = ADS1115_WE(&I2C_2, ADS1115_I2C_ADDRESS);

void setup() {
  Serial.begin(9600);
  
  I2C_1.begin(SDA_1, SCL_1, I2C_FREQ);
  I2C_2.begin(SDA_2, SCL_2, I2C_FREQ);
  
  setupAdc_1();
  setupAdc_2();
}

void loop() {
  float voltage = 0.0;
  
  voltage = adc_1.getResult_V();
  Serial.print("Voltage [V], ADS1115 No 1: ");
  Serial.println(voltage);

  voltage = adc_2.getResult_V();
  Serial.print("Voltage [V], ADS1115 No 2: ");
  Serial.println(voltage);
  
  Serial.println("*********************************");  
  delay(1000);
}

void setupAdc_1(){
  if(!adc_1.init()){
    Serial.println("ADS1115 No 1 not connected!");
  }
  adc_1.setVoltageRange_mV(ADS1115_RANGE_6144);
  adc_1.setCompareChannels(ADS1115_COMP_0_GND);
  adc_1.setMeasureMode(ADS1115_CONTINUOUS); 
}

void setupAdc_2(){
  if(!adc_2.init()){
    Serial.println("ADS1115 No 2 not connected!");
  }
  adc_2.setVoltageRange_mV(ADS1115_RANGE_6144);
  adc_2.setCompareChannels(ADS1115_COMP_0_GND);
  adc_2.setMeasureMode(ADS1115_CONTINUOUS); 
}

The code can be shortened because there are some identical function calls for adc_1 and adc_2. Thus, I introduce functions to which I pass these objects. Again, I don’t work with local copies of the objects, but with the originals. However, here I choose a different method to achieve this, namely using references as parameters. In the function call, for example:

setupAdc(adc_1, 1);

the use of references is not visible. However, in the function itself you can see the difference, and that is the address operator:

void setupAdc(ADS1115_WE &adc, byte i)

This introduces only another identifier for the object within the function.

#include<ADS1115_WE.h> 
#include<Wire.h>
#define ADS1115_I2C_ADDRESS  0x48
#define I2C_FREQ 400000

#define SDA_1 15
#define SCL_1 16
#define SDA_2 17
#define SCL_2 18

TwoWire I2C_1 = TwoWire(0);
TwoWire I2C_2 = TwoWire(1);
ADS1115_WE adc_1 = ADS1115_WE(&I2C_1, ADS1115_I2C_ADDRESS);
ADS1115_WE adc_2 = ADS1115_WE(&I2C_2, ADS1115_I2C_ADDRESS);

void setup() {
  Serial.begin(9600);
  
  I2C_1.begin(SDA_1, SCL_1, I2C_FREQ);
  I2C_2.begin(SDA_2, SCL_2, I2C_FREQ);
  
  setupAdc(adc_1, 1);
  setupAdc(adc_2, 2);
}

void loop() {
  queryAdc(adc_1, 1);
  queryAdc(adc_2, 2);
  
  Serial.println("*********************************");  
  delay(1000);
}

void setupAdc(ADS1115_WE &adc, byte i){
  if(!adc.init()){
    Serial.print("ADS1115 No ");
    Serial.print(i);
    Serial.println(" not connected!");
  }
  adc.setVoltageRange_mV(ADS1115_RANGE_6144);
  adc.setCompareChannels(ADS1115_COMP_0_GND);
  adc.setMeasureMode(ADS1115_CONTINUOUS); 
}

void queryAdc(ADS1115_WE &adc, byte i){
  float voltage = 0.0;
  
  voltage = adc.getResult_V();
  Serial.print("Voltage [V], ADS1115 No ");
  Serial.print(i);
  Serial.print(": ");
  Serial.println(voltage);
}

 

Variant 2: Use of Wire and an additional interface

Not because it would have advantages, but for completeness I would like to show that you can also use the predefined Wire object and an additionally created TwoWire object. Compared to the last circuit, only the I2C pins change:

Two ADS1115 attached the default Wire and an additional I2C interface
Two ADS1115 attached the default Wire and an additional I2C interface

I have called the additional TwoWire object Wire1. It must be created with TwoWire(1). In some places I have read that Wire1 is also predefined just like Wire. But without TwoWire Wire1 = TwoWire(1); it didn’t work for me.

Here’s what the sketch looks like:

#include<ADS1115_WE.h> 
#include<Wire.h>
#define ADS1115_I2C_ADDRESS  0x48
#define I2C_FREQ 400000
#define SDA_2 17
#define SCL_2 18

TwoWire Wire1 = TwoWire(1);

ADS1115_WE adc_1 = ADS1115_WE(ADS1115_I2C_ADDRESS);
ADS1115_WE adc_2 = ADS1115_WE(&Wire1, ADS1115_I2C_ADDRESS);

void setup() {
  Serial.begin(9600);
  
  Wire.begin();
  Wire1.begin(SDA_2, SCL_2, I2C_FREQ);
    
  setupAdc(adc_1, 1);
  setupAdc(adc_2, 2);
}

void loop() {
  queryAdc(adc_1, 1);
  queryAdc(adc_2, 2);
  Serial.println("*********************************");  
  delay(1000);
}

void setupAdc(ADS1115_WE &adc, byte i){
  if(!adc.init()){
      Serial.print("ADS1115 No ");
      Serial.print(i);
      Serial.println(" not connected!");
    }
    adc.setVoltageRange_mV(ADS1115_RANGE_6144);
    adc.setCompareChannels(ADS1115_COMP_0_GND);
    adc.setMeasureMode(ADS1115_CONTINUOUS); 
}

void queryAdc(ADS1115_WE &adc, byte i){
  float voltage = 0.0;
  
  voltage = adc.getResult_V();
  Serial.print("Voltage [V], ADS1115 No ");
  Serial.print(i);
  Serial.print(": ");
  Serial.println(voltage);
}

 

How to use I2C interfaces of the ESP32 without libraries

In previous examples, the I2C device was defined as an object via a library. If you work without a library, things are easier. Nevertheless, I will show an example using the I2C multiplexer TCA9548A because with this, I can explain the passing of pointers in more detail.

I chose the TCA9548A because it allows you to control additional I2C devices with the same address. So, we’ll stay on topic. In addition, in terms of control, it is the simplest I2C device I could find.

Since I covered the TCA9548A in my last post, I describe it here only roughly: The TCA9548A has an I2C input and eight I2C outputs. The channels are turned on and off by setting or deleting the corresponding bit in the control register. In this way, eight I2C components with the same address can be controlled addressed with one TCA9548A.

This is what it looks like when you connect two TCA9548A to the ESP32:

Two TCA9548A connected to an ESP32
Two TCA9548A connected to an ESP32

The following sketch turns on channel 3 of one module and channel 7 of the other:

#include<Wire.h>
#define TCA_I2C_ADDRESS  0x70
#define TCA_1_CHANNEL  3
#define TCA_2_CHANNEL  7
#define I2C_FREQ_1 400000
#define I2C_FREQ_2 400000

#define SDA_1 15
#define SCL_1 16
#define SDA_2 17
#define SCL_2 18

TwoWire I2C_1 = TwoWire(0);
TwoWire I2C_2 = TwoWire(1);

void setup() {
  I2C_1.begin(SDA_1, SCL_1, I2C_FREQ_1);
  I2C_2.begin(SDA_2, SCL_2, I2C_FREQ_2);
  setTCAChannel_1(TCA_1_CHANNEL);
  setTCAChannel_2(TCA_2_CHANNEL);
}

void loop() { 
}

void setTCAChannel_1(byte i){
  I2C_1.beginTransmission(TCA_I2C_ADDRESS);
  I2C_1.write(1 << i);
  I2C_1.endTransmission();  
}

void setTCAChannel_2(byte i){
  I2C_2.beginTransmission(TCA_I2C_ADDRESS);
  I2C_2.write(1 << i);
  I2C_2.endTransmission();  
}

The two setTCAChannel() functions can be merged by passing the TwoWire object as a reference:

void setTCAChannel(byte i, TwoWire &I2C){
  I2C.beginTransmission(TCA_I2C_ADDRESS);
  I2C.write(1 << i);
  I2C.endTransmission();  
}

 

How to pass pointers

However, there is another method, namely passing the objects as pointers. The receiving function looks as follows:

void setTCAChannel(byte i, TwoWire *I2C){
  TwoWire *wire = I2C;
  wire->beginTransmission(TCA_I2C_ADDRESS);
  wire->write(1 << i);
  wire->endTransmission();  
}

Passing the object as a pointer has two consequences:

  1. You cannot apply the point operator to pointers. If you want to use a function of the object to which the pointer points, the equivalent is the arrow operator.
  2. When calling the above function, the object to be passed must be preceded by the address operator.

And why do I tell all this? Because passing the TwoWire object works this way with most libraries and I can best show it in such a simple example.

The full sketch looks like this:

#include<Wire.h>
#define TCA_I2C_ADDRESS  0x70
#define TCA_1_CHANNEL  3
#define TCA_2_CHANNEL  7
#define I2C_FREQ_1 400000
#define I2C_FREQ_2 400000

#define SDA_1 15
#define SCL_1 16
#define SDA_2 17
#define SCL_2 18

TwoWire I2C_1 = TwoWire(0);
TwoWire I2C_2 = TwoWire(1);

void setup() {
  I2C_1.begin(SDA_1, SCL_1, I2C_FREQ_1);
  I2C_2.begin(SDA_2, SCL_2, I2C_FREQ_2);
  setTCAChannel(TCA_1_CHANNEL, &I2C_1);
  setTCAChannel(TCA_2_CHANNEL, &I2C_2);
}

void loop() { 
}

void setTCAChannel(byte i, TwoWire *I2C){
  TwoWire *wire = I2C;
  wire->beginTransmission(TCA_I2C_ADDRESS);
  wire->write(1 << i);
  wire->endTransmission();  
}

 

Update of my libraries

I’ve published a number of libraries on GitHub. With one exception, I’ve revised them over the last few weeks to allow TwoWire objects to be passed as an option. You find the libraries here.

Acknowledgement

I found the Fritzing component for the ESP32 here in the Fritzing Forum. Thanks to the designers.

Leave a Reply

Your email address will not be published. Required fields are marked *