Table of Contents
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 * * ┌──────────────────────────────────────────────────────────┐ * │ Application Layer │ * │ sensor_network_poll() → process → report via UART │ * ├──────────────────────────────────────────────────────────┤ * │ Sensor Abstraction Layer │ * │ sensor_t interface: init(), read(), get_name() │ * ├──────────┬──────────┬──────────┬─────────────────────────┤ * │ BMP280 │ SHT30 │ BH1750 │ (future sensors...) │ * │ driver │ driver │ driver │ │ * ├──────────┴──────────┴──────────┴─────────────────────────┤ * │ I2C HAL Layer │ * │ i2c_mem_read / i2c_mem_write │ * ├──────────────────────────────────────────────────────────┤ * │ I2C Hardware (I2C1 peripheral) │ * │ SDA (PB7) ──┬── SCL (PB6) │ * └──────────────────────┼────────────────────────────────────┘ * │ I2C Bus (with pull-ups) * ┌──────────┼──────────┬──────────┐ * │ │ │ │ * ┌───┴───┐ ┌───┴───┐ ┌───┴───┐ * │BMP280 │ │ SHT30 │ │BH1750 │ * │ 0x76 │ │ 0x44 │ │ 0x23 │ * └───────┘ └───────┘ └───────┘ */
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);
#endifBMP280 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] <dig_T2 = (int16_t)(calib[3] <dig_T3 = (int16_t)(calib[5] << 8 | calib[4]);
/* Configure: normal mode, temp oversample x2, press oversample x4 */
uint8_t ctrl = (0x02 << 5) | (0x03 <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 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 var1 = ((((adc_T >> 3) - ((int32_t)d->dig_T1 <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);
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 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 i2c, self->i2c_addr, cmd, 2, 100) != I2C_OK)
return -2;
/* Wait for measurement (~15ms for high repeatability) */
for (volatile int i = 0; i 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] <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 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 count; i++) {
sensor_t *s = net->sensors[i];
measurement_t meas[4];
int count = s->read(s, meas, 4);
s->read_count++;
if (count 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 latest_count 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 latest_count; i++) {
measurement_t *m = &net->latest[i];
if (!m->valid) continue;
int32_t whole = m->value / 100;
int32_t frac = (m->value 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 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.
/* Using TCA9548A I2C multiplexer for address conflicts
*
* Problem: You need two BMP280 sensors but both have address 0x76.
* Solution: Use a TCA9548A 8-channel I2C multiplexer.
*
* ┌──────────┐
* SDA ──────┤ SDA SD0 ├────── BMP280 #1 (0x76)
* SCL ──────┤ SCL SC0 │
* │ SD1 ├────── BMP280 #2 (0x76)
* 0x70 ──── ┤ A0 SC1 │
* │ SD2 ├────── SHT30 (0x44)
* │ SC2 │
* └──────────┘
*
* 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
- I2C Protocol Deep Dive
- Writing an I2C Driver in Embedded C
- Decoupling Capacitors in Embedded Design
- Using a Logic Analyzer for Embedded Debugging
- Debugging Communication Protocols
- Interfacing Sensors with Microcontrollers
- Complete Guide to Sensors
- Temperature Sensors Explained
- Sensor Calibration Techniques
- Noise in Sensor Measurements
📖 Related: Writing an SPI Driver in Embedded C: Complete Implementation • LM35 Temperature Sensor: Working, Circuit, and Arduino Code

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.


