Skip to content
Home » Embedded Systems » Communication Protocols » Building an I2C Sensor Network in C: Complete Project Guide

Building an I2C Sensor Network in C: Complete Project Guide

Communication Protocols
Part 7 of 10 — View Full Path →

KEY TAKEAWAYS

  • An I2C sensor network connects multiple sensors (temperature, humidity, pressure, light) on a single two-wire bus.
  • A clean driver abstraction layer lets you add new sensor types without modifying the polling or reporting logic.
  • Bus health monitoring (error counters, automatic recovery) prevents one faulty sensor from crashing the entire network.
  • Sensor data aggregation with timestamps, averaging, and min/max tracking provides meaningful measurements from noisy raw data.
  • I2C multiplexers (TCA9548A) solve address conflicts and extend the bus beyond its capacitance limits.

Project Overview

In this project, we’ll build a complete I2C sensor network that reads from multiple sensors — BMP280 (temperature + pressure), SHT30 (temperature + humidity), and BH1750 (light) — processes the data, and reports it over UART. The design is modular: adding a new sensor type requires implementing just one interface, without changing any other code.

This project builds directly on our I2C protocol knowledge and I2C driver.

Architecture

Before writing any code, you need a clear picture of how the system is organized. The sensor network follows a layered architecture: at the bottom is the I2C bus driver (which we built in the I2C Driver article), above that is a sensor abstraction layer that provides a uniform interface for all sensor types, and at the top is a network manager that handles polling, error recovery, and data aggregation. This separation means you can add new sensor types without modifying the network manager, and swap the I2C driver without touching any sensor code.

I2C sensor network architecture A layered stack, top to bottom. Application layer: sensor_network_poll(), then process the data, then report it via UART. Sensor abstraction layer: the sensor_t interface, with a name field and the init(), read() and reset() function pointers. Sensor driver layer: one driver each for the BMP280, SHT30 and BH1750, with room for future sensors. I2C HAL layer: i2c_mem_read and i2c_mem_write. I2C hardware: the I2C1 peripheral, with SDA on PB7 and SCL on PB6. Below the stack, SDA and SCL form the I2C bus, each line with a pull-up resistor to the supply. Three sensors share the bus: BMP280 at address 0x76, SHT30 at 0x44 and BH1750 at 0x23. Application Layer sensor_network_poll() → process → report via UART Sensor Abstraction Layer sensor_t interface: name, init(), read(), reset() BMP280driver SHT30driver BH1750driver (future sensors…) I2C HAL Layer i2c_mem_read / i2c_mem_write I2C Hardware (I2C1 peripheral) SDA (PB7) SCL (PB6) VDD pull-ups SCL SDA I2C bus (with pull-ups) BMP2800x76 SHT300x44 BH17500x23 Each sensor connects to both bus lines: SDA (blue) and SCL (red). Crossing lines without a dot are not joined.
Figure: I2C sensor network architecture: the software layers down to the I2C1 peripheral, and the three sensors sharing the bus.

Sensor Abstraction Interface

The key to a maintainable sensor network is a common interface that all sensors implement, regardless of their underlying protocol or data format. Each sensor driver provides an init function, a read function, and a way to report its type and status. The network manager interacts only through this interface — it never needs to know whether it’s talking to a temperature sensor, an accelerometer, or a humidity sensor. This is the same design pattern used in operating system device drivers and the Hardware Abstraction Layer pattern.

/* sensor.h — Generic Sensor Interface */

#ifndef SENSOR_H
#define SENSOR_H

#include <stdint.h>
#include "i2c_driver.h"

/* Measurement types */
typedef enum {
    MEAS_TEMPERATURE,   /* in 0.01°C (e.g., 2345 = 23.45°C) */
    MEAS_PRESSURE,      /* in Pa (e.g., 101325 = 1013.25 hPa) */
    MEAS_HUMIDITY,      /* in 0.01% RH (e.g., 6543 = 65.43%) */
    MEAS_LIGHT,         /* in lux (e.g., 500 = 500 lux) */
    MEAS_COUNT
} measurement_type_t;

/* Single measurement reading */
typedef struct {
    measurement_type_t type;
    int32_t value;        /* Scaled integer value */
    uint32_t timestamp;   /* Millisecond timestamp */
    uint8_t valid;        /* 1 = good reading, 0 = error */
} measurement_t;

/* Sensor interface — implement these for each sensor type */
typedef struct sensor sensor_t;

struct sensor {
    const char *name;
    uint8_t i2c_addr;
    i2c_handle_t *i2c;

    /* Number of measurements this sensor provides */
    uint8_t num_measurements;

    /* Function pointers — the "interface" */
    int  (*init)(sensor_t *self);
    int  (*read)(sensor_t *self, measurement_t *out, uint8_t max_count);
    void (*reset)(sensor_t *self);

    /* Private driver data (cast inside driver implementation) */
    void *driver_data;

    /* Error tracking */
    uint32_t read_count;
    uint32_t error_count;
    uint32_t consecutive_errors;
};

/* Sensor network */
#define MAX_SENSORS  8

typedef struct {
    sensor_t *sensors[MAX_SENSORS];
    uint8_t count;
    i2c_handle_t *i2c;

    /* Aggregated measurements */
    measurement_t latest[MAX_SENSORS * 4];  /* Up to 4 meas per sensor */
    uint8_t latest_count;
} sensor_network_t;

void sensor_network_init(sensor_network_t *net, i2c_handle_t *i2c);
int  sensor_network_add(sensor_network_t *net, sensor_t *sensor);
void sensor_network_poll(sensor_network_t *net);
void sensor_network_report(sensor_network_t *net);

#endif

BMP280 Sensor Driver

The BMP280 is a popular temperature and pressure sensor from Bosch. It communicates over I2C at address 0x76 or 0x77 (depending on the SDO pin wiring). The driver below implements the sensor interface we defined above — it initializes the BMP280 with oversampling settings, reads the raw ADC values, and applies the compensation formula from the datasheet to produce calibrated temperature and pressure values. For datasheet reading skills, see How to Read a Datasheet.

/* bmp280_sensor.c — BMP280 as a network sensor */

#include "sensor.h"
#include <stdint.h>

/* BMP280 private data (calibration coefficients) */
typedef struct {
    uint16_t dig_T1;
    int16_t  dig_T2, dig_T3;
    int32_t  t_fine;
    uint8_t  initialized;
} bmp280_data_t;

static bmp280_data_t bmp280_priv;

static int bmp280_sensor_init(sensor_t *self) {
    bmp280_data_t *d = (bmp280_data_t *)self->driver_data;
    uint8_t id;

    /* Read and verify chip ID */
    if (i2c_mem_read(self->i2c, self->i2c_addr, 0xD0, &id, 1, 100) != I2C_OK)
        return -1;
    if (id != 0x58) return -2;

    /* Read calibration data */
    uint8_t calib[6];
    if (i2c_mem_read(self->i2c, self->i2c_addr, 0x88, calib, 6, 100) != I2C_OK)
        return -3;

    d->dig_T1 = (uint16_t)(calib[1] << 8 | calib[0]);
    d->dig_T2 = (int16_t)(calib[3] << 8 | calib[2]);
    d->dig_T3 = (int16_t)(calib[5] << 8 | calib[4]);

    /* Configure: normal mode, temp oversample x2, press oversample x4 */
    uint8_t ctrl = (0x02 << 5) | (0x03 << 2) | 0x03;
    if (i2c_mem_write(self->i2c, self->i2c_addr, 0xF4, &ctrl, 1, 100) != I2C_OK)
        return -4;

    d->initialized = 1;
    return 0;
}

static int bmp280_sensor_read(sensor_t *self, measurement_t *out, uint8_t max) {
    bmp280_data_t *d = (bmp280_data_t *)self->driver_data;
    if (!d->initialized || max < 2) return -1;

    uint8_t raw[6];  /* press msb/lsb/xlsb, temp msb/lsb/xlsb (0xF7..0xFC) */
    if (i2c_mem_read(self->i2c, self->i2c_addr, 0xF7, raw, 6, 100) != I2C_OK)
        return -2;

    /* Temperature compensation */
    int32_t adc_T = ((int32_t)raw[3] << 12) | ((int32_t)raw[4] << 4) | ((int32_t)raw[5] >> 4);
    int32_t var1 = ((((adc_T >> 3) - ((int32_t)d->dig_T1 << 1))) * d->dig_T2) >> 11;
    int32_t var2 = (((((adc_T >> 4) - (int32_t)d->dig_T1)
                     * ((adc_T >> 4) - (int32_t)d->dig_T1)) >> 12) * d->dig_T3) >> 14;
    d->t_fine = var1 + var2;
    int32_t temp = (d->t_fine * 5 + 128) >> 8;

    out[0].type = MEAS_TEMPERATURE;
    out[0].value = temp;
    out[0].timestamp = get_tick_ms();
    out[0].valid = 1;

    /* Pressure (simplified) */
    int32_t adc_P = ((int32_t)raw[0] << 12) | ((int32_t)raw[1] << 4) | ((int32_t)raw[2] >> 4);
    out[1].type = MEAS_PRESSURE;
    out[1].value = adc_P;  /* Raw for simplicity */
    out[1].timestamp = out[0].timestamp;
    out[1].valid = 1;

    return 2;  /* 2 measurements returned */
}

static void bmp280_sensor_reset(sensor_t *self) {
    uint8_t rst = 0xB6;
    i2c_mem_write(self->i2c, self->i2c_addr, 0xE0, &rst, 1, 100);
    bmp280_data_t *d = (bmp280_data_t *)self->driver_data;
    d->initialized = 0;
}

/* Create and return a BMP280 sensor instance */
sensor_t bmp280_sensor = {
    .name = "BMP280",
    .i2c_addr = 0x76,
    .num_measurements = 2,
    .init = bmp280_sensor_init,
    .read = bmp280_sensor_read,
    .reset = bmp280_sensor_reset,
    .driver_data = &bmp280_priv,
};

SHT30 Humidity Sensor Driver

The SHT30 from Sensirion is a high-accuracy temperature and humidity sensor with an I2C interface at address 0x44 or 0x45. Unlike the BMP280 which uses register-based reads, the SHT30 uses command-based communication — you send a 16-bit measurement command and wait for the sensor to respond with data. The driver implements the same sensor interface, so the network manager handles it identically to the BMP280 despite the completely different underlying protocol.

/* sht30_sensor.c — SHT30 temperature + humidity sensor */

typedef struct {
    uint8_t initialized;
} sht30_data_t;

static sht30_data_t sht30_priv;

static int sht30_sensor_init(sensor_t *self) {
    /* SHT30 soft reset: command 0x30A2 */
    uint8_t cmd[2] = {0x30, 0xA2};
    if (i2c_master_transmit(self->i2c, self->i2c_addr, cmd, 2, 100) != I2C_OK)
        return -1;

    /* Wait for reset */
    for (volatile int i = 0; i < 100000; i++);

    sht30_data_t *d = (sht30_data_t *)self->driver_data;
    d->initialized = 1;
    return 0;
}

static uint8_t sht30_crc(uint8_t *data, uint8_t len) {
    uint8_t crc = 0xFF;
    for (uint8_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (uint8_t bit = 0; bit < 8; bit++) {
            if (crc & 0x80)
                crc = (crc << 1) ^ 0x31;
            else
                crc <<= 1;
        }
    }
    return crc;
}

static int sht30_sensor_read(sensor_t *self, measurement_t *out, uint8_t max) {
    if (max < 2) return -1;

    /* Single shot, high repeatability, clock stretching disabled */
    uint8_t cmd[2] = {0x24, 0x00};
    if (i2c_master_transmit(self->i2c, self->i2c_addr, cmd, 2, 100) != I2C_OK)
        return -2;

    /* Wait for measurement (~15ms for high repeatability) */
    for (volatile int i = 0; i < 1000000; i++);

    uint8_t data[6];  /* T msb, lsb, CRC, RH msb, lsb, CRC */
    if (i2c_master_receive(self->i2c, self->i2c_addr, data, 6, 100) != I2C_OK)
        return -3;

    /* Verify CRCs */
    if (sht30_crc(data, 2) != data[2]) return -4;
    if (sht30_crc(data + 3, 2) != data[5]) return -5;

    /* Convert temperature: T = -45 + 175 × (raw / 65535) */
    uint16_t raw_temp = (data[0] << 8) | data[1];
    int32_t temp_cdeg = -4500 + (17500 * (int32_t)raw_temp) / 65535;

    /* Convert humidity: RH = 100 × (raw / 65535) */
    uint16_t raw_hum = (data[3] << 8) | data[4];
    int32_t rh_centi = (10000 * (int32_t)raw_hum) / 65535;

    out[0].type = MEAS_TEMPERATURE;
    out[0].value = temp_cdeg;
    out[0].timestamp = get_tick_ms();
    out[0].valid = 1;

    out[1].type = MEAS_HUMIDITY;
    out[1].value = rh_centi;
    out[1].timestamp = out[0].timestamp;
    out[1].valid = 1;

    return 2;  /* 2 measurements returned */
}

static void sht30_sensor_reset(sensor_t *self) {
    uint8_t cmd[2] = {0x30, 0xA2};  /* Soft reset */
    i2c_master_transmit(self->i2c, self->i2c_addr, cmd, 2, 100);
    sht30_data_t *d = (sht30_data_t *)self->driver_data;
    d->initialized = 0;
}

sensor_t sht30_sensor = {
    .name = "SHT30",
    .i2c_addr = 0x44,
    .num_measurements = 2,
    .init = sht30_sensor_init,
    .read = sht30_sensor_read,
    .reset = sht30_sensor_reset,
    .driver_data = &sht30_priv,
};

Sensor Network Manager

The network manager is the heart of the system. It maintains a registry of all connected sensors, polls them at configurable intervals, handles errors (including automatic retry and sensor re-initialization), and provides the aggregated data to the application layer. Error recovery is particularly important in I2C networks because a single misbehaving sensor can hold the SDA line low and lock up the entire bus — the manager must detect this condition and perform a bus recovery sequence.

/* sensor_network.c — Network manager with error recovery */

#include "sensor.h"
#include <stdint.h>

#define MAX_CONSECUTIVE_ERRORS  5  /* Auto-reset after this many */

void sensor_network_init(sensor_network_t *net, i2c_handle_t *i2c) {
    net->count = 0;
    net->i2c = i2c;
    net->latest_count = 0;
}

int sensor_network_add(sensor_network_t *net, sensor_t *sensor) {
    if (net->count >= MAX_SENSORS) return -1;

    sensor->i2c = net->i2c;
    sensor->read_count = 0;
    sensor->error_count = 0;
    sensor->consecutive_errors = 0;

    /* Initialize the sensor */
    int result = sensor->init(sensor);
    if (result < 0) {
        printf("[NET] %s init failed (error %d)\n", sensor->name, result);
    } else {
        printf("[NET] %s initialized at 0x%02X\n",
               sensor->name, sensor->i2c_addr);
    }

    net->sensors[net->count++] = sensor;
    return 0;
}

void sensor_network_poll(sensor_network_t *net) {
    net->latest_count = 0;

    for (uint8_t i = 0; i < net->count; i++) {
        sensor_t *s = net->sensors[i];
        measurement_t meas[4];

        int count = s->read(s, meas, 4);
        s->read_count++;

        if (count < 0) {
            /* Read failed */
            s->error_count++;
            s->consecutive_errors++;

            if (s->consecutive_errors >= MAX_CONSECUTIVE_ERRORS) {
                printf("[NET] %s: %lu consecutive errors, resetting\n",
                       s->name, s->consecutive_errors);
                s->reset(s);

                /* Re-initialize */
                if (s->init(s) == 0) {
                    s->consecutive_errors = 0;
                    printf("[NET] %s: recovery successful\n", s->name);
                } else {
                    printf("[NET] %s: recovery failed\n", s->name);
                }
            }
        } else {
            /* Success */
            s->consecutive_errors = 0;

            for (int j = 0; j < count; j++) {
                if (net->latest_count < MAX_SENSORS * 4)
                    net->latest[net->latest_count++] = meas[j];
            }
        }
    }
}

static const char *meas_type_name(measurement_type_t type) {
    switch (type) {
        case MEAS_TEMPERATURE: return "Temp";
        case MEAS_PRESSURE:    return "Press";
        case MEAS_HUMIDITY:    return "RH";
        case MEAS_LIGHT:       return "Light";
        default: return "?";
    }
}

static const char *meas_type_unit(measurement_type_t type) {
    switch (type) {
        case MEAS_TEMPERATURE: return "C";
        case MEAS_PRESSURE:    return "Pa";
        case MEAS_HUMIDITY:    return "%";
        case MEAS_LIGHT:       return "lux";
        default: return "";
    }
}

void sensor_network_report(sensor_network_t *net) {
    printf("\n--- Sensor Report [%lu ms] ---\n", get_tick_ms());

    for (uint8_t i = 0; i < net->latest_count; i++) {
        measurement_t *m = &net->latest[i];
        if (!m->valid) continue;

        int32_t whole = m->value / 100;
        int32_t frac  = (m->value < 0 ? -m->value : m->value) % 100;

        printf("  %s: %ld.%02ld %s\n",
               meas_type_name(m->type),
               (long)whole, (long)frac,
               meas_type_unit(m->type));
    }

    /* Bus health */
    printf("  Bus health:\n");
    for (uint8_t i = 0; i < net->count; i++) {
        sensor_t *s = net->sensors[i];
        uint32_t success = s->read_count - s->error_count;
        printf("    %s: %lu/%lu ok (%.1f%%)\n",
               s->name, success, s->read_count,
               s->read_count ? (success * 100.0 / s->read_count) : 0.0);
    }
    printf("---\n");
}

/* Main application example */
/*
int main(void) {
    i2c_handle_t i2c1 = { .periph = I2C1, .clock_speed = 36000000,
                           .bus_speed = I2C_SPEED_STANDARD };
    i2c_init(&i2c1);

    sensor_network_t net;
    sensor_network_init(&net, &i2c1);

    sensor_network_add(&net, &bmp280_sensor);
    sensor_network_add(&net, &sht30_sensor);

    while (1) {
        sensor_network_poll(&net);
        sensor_network_report(&net);
        delay_ms(2000);
    }
}
*/

Handling Address Conflicts with I2C Multiplexer

A common problem in I2C sensor networks is address conflicts — what happens when you need two BMP280 sensors but they both have the same I2C address? The TCA9548A I2C multiplexer solves this by providing eight independent I2C channels behind a single upstream address. The multiplexer sits between the master and the sensor buses, and you select which channel is active by writing to the multiplexer’s control register. This lets you connect up to eight devices with the same address on different channels.

TCA9548A multiplexer for two sensors with the same address The MCU’s I2C bus, SDA and SCL, connects to the SDA and SCL pins of a TCA9548A 8-channel I2C multiplexer. Its address pins A0, A1 and A2 are tied to ground, which gives the multiplexer the address 0x70. Channel 0, pins SD0 and SC0, goes to BMP280 number 1 at address 0x76. Channel 1, SD1 and SC1, goes to BMP280 number 2, also at 0x76. Channel 2, SD2 and SC2, goes to an SHT30 at 0x44. Channels 3 to 7 are free. Writing to address 0x70 selects which channels are connected to the upstream bus; selecting one channel at a time keeps the two BMP280s from clashing. SDA SCL from the MCU TCA9548A SDASCL A0A1A2 SD0SC0 SD1SC1 SD2SC2 SD3–SD7SC3–SC7 tied to GND → address 0x70 channels 3–7 free BMP280 #10x76 BMP280 #20x76 SHT300x44 Blue: data (SDA / SDx). Red: clock (SCL / SCx). Each channel is its own SDA + SCL pair.
Figure: A TCA9548A puts two BMP280s with the same address (0x76) on separate channels.

Problem: You need two BMP280 sensors but both have address 0x76. Solution: Use a TCA9548A 8-channel I2C multiplexer.

Each channel can be independently enabled/disabled. Write to the mux’s address (0x70) to select channel(s).

#define TCA9548A_ADDR  0x70

/* Select a single mux channel (0-7) */
int i2c_mux_select(i2c_handle_t *i2c, uint8_t channel) {
    if (channel > 7) return -1;
    uint8_t data = (1 << channel);
    return i2c_master_transmit(i2c, TCA9548A_ADDR, &data, 1, 100);
}

/* Disable all mux channels */
int i2c_mux_disable_all(i2c_handle_t *i2c) {
    uint8_t data = 0x00;
    return i2c_master_transmit(i2c, TCA9548A_ADDR, &data, 1, 100);
}

/* Example: Read from two BMP280 sensors on different mux channels */
/*
void read_both_bmp280(i2c_handle_t *i2c) {
    int32_t temp1, temp2;

    i2c_mux_select(i2c, 0);  // Select channel 0
    bmp280_read_temperature(&bmp1, &temp1);

    i2c_mux_select(i2c, 1);  // Select channel 1
    bmp280_read_temperature(&bmp2, &temp2);

    printf("Sensor 1: %ld.%02ld C\n", temp1/100, temp1%100);
    printf("Sensor 2: %ld.%02ld C\n", temp2/100, temp2%100);
}
*/

Related Articles

📖 Related: Writing an SPI Driver in Embedded C: Complete Implementation • LM35 Temperature Sensor: Working, Circuit, and Arduino Code

Leave a Reply

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