Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 29

Reading I2C Temperature and Humidity Data

Recommended resources

Hello! Welcome to our next lesson.

In our last session, we mastered the high-speed SPI protocol, completing our tour of the three fundamental serial interfaces in embedded systems. We saw how the ESP-IDF's bus-device model for peripherals provides a consistent API structure.

Today, we will return to the I2C protocol to tackle a more advanced and highly practical challenge. This lesson is designed to help you achieve the learning outcome: Parse multi-byte temperature and humidity data from a common I2C environmental sensor.

Many sophisticated peripherals, like environmental sensors, don't just send a single byte of information. Instead, they provide raw, multi-byte measurements that require processing. In this lesson, we will learn how to read this data and transform it into meaningful values like degrees Celsius and relative humidity.

We will cover:

  • The process of reading multiple bytes from a sensor's registers.
  • How to use bitwise operations to combine these bytes into a single raw value.
  • The crucial role of factory calibration data in compensating raw sensor readings for accuracy.
  • A C++/ESP-IDF implementation that encapsulates this entire logic in a reusable class.

1. The Challenge of Multi-Byte Sensor Data

When an I2C sensor takes a measurement, such as temperature, the result is often a high-resolution value stored in an internal Analog-to-Digital Converter (ADC). This value might be 16, 20, or even 24 bits long. However, I2C communication is fundamentally byte-oriented, meaning data is transferred in 8-bit chunks.

How does the sensor transmit a 16-bit temperature reading over an 8-bit bus? It breaks the value down into multiple bytes. Typically, these are the Most Significant Byte (MSB) and the Least Significant Byte (LSB).

  • MSB: The "high" byte, containing the upper 8 bits of the value.
  • LSB: The "low" byte, containing the lower 8 bits of the value.

The microcontroller's job is to read these two bytes from the sensor (usually from two different registers) and then reconstruct the original 16-bit number. This is done using bitwise operations:

  1. Bit-Shifting: The MSB is shifted 8 bits to the left (<< 8). This effectively multiplies its value by 256, moving it into the correct position in a 16-bit integer.
  2. Bitwise OR: The shifted MSB is then combined with the LSB using a bitwise OR (|) operation.

The C++ expression looks like this:
uint16_t raw_value = (uint16_t)(msb << 8) | lsb;

This technique is fundamental to working with a vast range of digital sensors.

Test your understanding!

A pressure sensor sends its reading as two bytes. You read the MSB from register 0xF7 and get the value 0x6C. You read the LSB from register 0xF8 and get 0x50. What is the C++ expression to combine these into a 16-bit unsigned integer named raw_pressure?

Show answer
uint8_t msb = 0x6C;
uint8_t lsb = 0x50;
uint16_t raw_pressure = (uint16_t)(msb << 8) | lsb; 
// raw_pressure will be 0x6C50

2. A Concrete Example: Reading an AHT20 Sensor

Let's see this in action with a real sensor. The AHT20 is a common temperature and humidity sensor. Its datasheet specifies the exact sequence of commands and the format of the data it returns.

After triggering a measurement, the AHT20 provides 6 bytes of data. The temperature and humidity are both 20-bit values packed into these bytes in a specific way.

ESP32 I²C Demo – Basic Wire Code for AHT20

The following article from DroneBot Workshop provides an excellent code example showing how to manually parse the data from an AHT20 sensor using the Arduino framework. While the API is different from ESP-IDF, the core logic of byte manipulation is universal and very clearly demonstrated here.

Please read the section 'ESP32 I²C Demo – Basic Wire Code for AHT20'. Focus on the code inside the loop() function, specifically these lines: The call to Wire.requestFrom(AHT20_ADDR, (uint8_t)6); which reads the 6 bytes of data. The two lines that combine the bytes into rawHumidity and rawTemp. Analyze how bit-shifting (<<, >>) and bitwise OR (|) are used to extract 20-bit values from different parts of the 6-byte data array.

Let's dissect the humidity calculation from the article:
uint32_t rawHumidity = ((uint32_t)data[1] << 12) | ((uint32_t)data[2] << 4) | ((uint32_t)data[3] >> 4);

This shows a more complex combination than our simple MSB/LSB example:

  • data[1] << 12: The first full byte of humidity data is shifted into the highest position.
  • data[2] << 4: The second full byte is shifted up by 4 bits.
  • data[3] >> 4: Only the upper 4 bits of the third byte are part of the humidity reading, so the byte is shifted right to discard the lower 4 bits.

These three pieces are then combined with | to form the final 20-bit raw humidity value. The datasheet for each specific sensor is the ultimate source for these formulas.


3. Beyond Raw Data: Calibration and Compensation

The raw value we just assembled is directly from the sensor's ADC. However, due to tiny imperfections in the manufacturing process, this raw value isn't perfectly accurate. To correct this, manufacturers test each individual sensor at the factory and store a unique set of calibration coefficients directly on the chip's non-volatile memory.

To get an accurate reading, your program must:

  1. At initialization: Read these multi-byte calibration coefficients from the sensor.
  2. For each measurement:
    • Read the raw ADC value for temperature/humidity.
    • Plug the raw value AND the calibration coefficients into a special compensation formula provided by the manufacturer in the sensor's datasheet.

This process is critical for achieving the accuracy advertised by the sensor.

ESP32 with ESP-IDF#5 | ESP32 as I2C Master

This video uses the ESP-IDF to communicate with a BMP180 sensor. It's a great demonstration of reading both calibration and raw data and then combining bytes.

Please watch from 06:51 to 09:27. The code is in C, but the concepts are directly applicable. Notice how: The read_calibration_data function reads multiple two-byte values from the sensor. Inside that function, left Shifting the first bite by eight bits and using logical or with the second bite is exactly the MSB/LSB reconstruction we discussed. To get the temperature, the code first writes a command and then reads a two-byte result, which is again merged from MSB and LSB. The video then mentions that a final calculation is needed using the raw data and calibration values (the algorithm is in the datasheet).


4. A Full C++/ESP-IDF Implementation with the BME280

Now let's bring all these concepts together into a clean C++ class for the BME280, a popular and more advanced sensor that measures temperature, humidity, and pressure. We will create a complete driver.

SHT31 Sensor Module with I2C Interface
A common I2C environmental sensor module, the SHT31. It has four pins: power (VIN), ground (GND), and the two I2C lines (SCL and SDA).

For the physical connection, you would connect the sensor to your ESP32 as shown in the diagram below. The specific GPIO pins can be changed in software, but the I2C connections (SDA to SDA, SCL to SCL) are standard.

ESP32 to SHT3x Sensor Wiring Diagram
Wiring diagram for connecting an I2C sensor to an ESP32. The sensor is powered by the ESP32's 3.3V supply, and the I2C lines GPIO 21 (SDA) and GPIO 22 (SCL) are connected.

The following resource provides a complete C++ driver for the BME280 using ESP-IDF. It's an excellent example of professional-quality embedded C++ code.

ESP-IDF Weather Station with BME280 C++

This comprehensive tutorial from EmbeddedTutorials walks through creating a full-featured C++ driver for the BME280 sensor. We will study its implementation to understand how to handle data parsing and compensation in a structured way.

This is a detailed resource. Please focus on the following key methods within the bme280_common.cpp source file (in the second code block): getCalibrateData(): Examine this method to see how it reads many different calibration coefficients (e.g., dig_t1, dig_h1, dig_p1) from the sensor. Note the tricky logic for dig_h4 and dig_h5, which are packed into shared registers. getSensorData(): This is where the raw measurements are read. Notice the call to readBlockData to fetch 8 bytes at once, starting from the pressure MSB register. Then, see how bitwise operations (<<, >>, |) are used to assemble the raw temperature, pressure, and humididty values. compensateHumidity(): Look at this function. You don't need to understand the complex math, but recognize that it takes the raw ADC value (adc_H) and uses the calibration values (e.g., dig_h1, dig_h2) to compute the final, accurate humidity percentage. GetAllResults(): Finally, see how this public method orchestrates the whole process: it calls getSensorData() to get the raw values, then passes them to the compensation functions (compensateTemp, compensateHumidity, etc.) to produce the final results.

This C++ class is a perfect illustration of abstraction. The user of this class simply calls bme280i2c.GetAllResults(&i2cTemperature, &i2cHumidity, &i2cPressure); and gets clean, compensated data. All the complexity of register addresses, bit-shifting, and compensation math is hidden away inside the class's private methods. This is the power of good object-oriented design in an embedded context.


Conclusion

Congratulations! You have now learned one of the most important skills for interfacing with modern sensors. You've gone from the basic I2C handshake to parsing complex, multi-byte data streams and applying datasheet-specific compensation formulas.

Key Takeaways:

  • Sensors often transmit high-resolution data (e.g., 16-bit or 20-bit) as a sequence of 8-bit bytes.
  • Bitwise operations (<<, >>, |) are the tools we use to reassemble these bytes into a single raw integer value.
  • Raw ADC values are not the final measurement. They must be processed using compensation formulas and calibration coefficients unique to each sensor.
  • The sensor's datasheet is your most important document; it specifies the register map, data format, and all necessary compensation algorithms.
  • Encapsulating all this logic within a C++ class is an excellent way to create a clean, reusable, and easy-to-use driver for your sensor.

In our next lesson, we will address a crucial aspect of robust embedded programming: handling errors. We'll learn how to check the return codes from our I2C and SPI API calls to make our applications more reliable and easier to debug when things go wrong.