Mastering C++ Design Patterns for I2C Management on ESP32
This blog introduces how to use ESP-IDF on the ESP32 platform, in conjunction with C++ design patterns, to architect a robust and scalable I2C bus management system. By applying design patterns such as the Factory Pattern and Observer Pattern, we can simplify the management of multiple I2C buses and devices. Additionally, design patterns are leveraged to ensure efficient and modular logging of system statuses and data operations using esp_log
.
In the world of IoT and embedded systems, I2C (Inter-Integrated Circuit) is a widely used communication protocol. When a system contains multiple I2C buses, each with several devices, managing the read/write operations of these buses and devices can become quite complex.
Contents
Design Goals and Architecture
The goal is to create a flexible and scalable I2C bus management system on the ESP32 using design patterns to meet the following requirements:
- Multiple I2C Buses: Support for multiple I2C buses.
- Multiple I2C Devices: Each bus can accommodate multiple I2C devices.
- Scanning Functionality: The ability to scan the I2C bus for device addresses, useful for verifying component connections.
- Data Read/Write Functionality: Ability to perform data read/write operations on specific devices.
- Observable Data Changes: Support for the Observer Pattern, where devices notify observers when data changes.
Development Environment Setup
- Install Visual Studio Code from the official website.
- Install the ESP-IDF plugin.
Design Patterns Used
- Factory Pattern: Dynamically manage the initialization and instantiation of multiple I2C buses.
- Observer Pattern: Allows devices on the I2C bus to receive notifications when data changes.
Programming Methodology
The following steps will guide you through using ESP32’s ESP-IDF and C++ design patterns to set up multiple I2C buses, manage devices, and use esp_log
for logging I2C operations, errors, and data changes. This also allows easy tracking and debugging.
Defining the I2C Bus Manager Class (I2CBusManager)
I2CBusManager is a class responsible for managing a single I2C bus, including initialization, data read/write operations, device scanning, and notifying registered observers (devices on the bus).
#include "driver/i2c.h"
#include "esp_log.h"
#include <vector>
class I2CDeviceObserver {
public:
virtual void onDataReceived(uint8_t* data, size_t length) = 0;
};
class I2CBusManager {
private:
i2c_port_t i2c_port;
int sda_pin;
int scl_pin;
std::vector<I2CDeviceObserver*> observers;
public:
I2CBusManager(i2c_port_t port, int sda, int scl, uint32_t clk_speed)
: i2c_port(port), sda_pin(sda), scl_pin(scl) {
i2c_config_t config;
config.mode = I2C_MODE_MASTER;
config.sda_io_num = sda_pin;
config.scl_io_num = scl_pin;
config.sda_pullup_en = GPIO_PULLUP_ENABLE;
config.scl_pullup_en = GPIO_PULLUP_ENABLE;
config.master.clk_speed = clk_speed;
ESP_ERROR_CHECK(i2c_param_config(i2c_port, &config));
ESP_ERROR_CHECK(i2c_driver_install(i2c_port, config.mode, 0, 0, 0));
ESP_LOGI("I2CBusManager", "I2C bus initialized on I2C_NUM_%d with SDA:%d, SCL:%d", i2c_port, sda_pin, scl_pin);
}
void scanBus() {
ESP_LOGI("I2CBusManager", "Starting I2C bus scan on I2C_NUM_%d...", i2c_port);
for (uint8_t address = 0x03; address < 0x78; ++address) {
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (address << 1) | I2C_MASTER_WRITE, true);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(i2c_port, cmd, 10 / portTICK_PERIOD_MS);
i2c_cmd_link_delete(cmd);
if (ret == ESP_OK) {
ESP_LOGI("I2CBusManager", "Device found at address 0x%X on I2C_NUM_%d", address, i2c_port);
}
}
ESP_LOGI("I2CBusManager", "I2C bus scan completed on I2C_NUM_%d", i2c_port);
}
void addObserver(I2CDeviceObserver* observer) {
observers.push_back(observer);
}
void notifyObservers(uint8_t* data, size_t length) {
for (auto observer : observers) {
observer->onDataReceived(data, length);
}
}
void writeToDevice(uint8_t address, uint8_t* data, size_t length) {
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (address << 1) | I2C_MASTER_WRITE, true);
i2c_master_write(cmd, data, length, true);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(i2c_port, cmd, 1000 / portTICK_PERIOD_MS);
i2c_cmd_link_delete(cmd);
if (ret == ESP_OK) {
ESP_LOGI("I2CBusManager", "Data written to device at 0x%X on I2C_NUM_%d", address, i2c_port);
} else {
ESP_LOGE("I2CBusManager", "Failed to write to device at 0x%X on I2C_NUM_%d", address, i2c_port);
}
}
void readFromDevice(uint8_t address, uint8_t* data, size_t length) {
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (address << 1) | I2C_MASTER_READ, true);
i2c_master_read(cmd, data, length, I2C_MASTER_LAST_NACK);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(i2c_port, cmd, 1000 / portTICK_PERIOD_MS);
i2c_cmd_link_delete(cmd);
if (ret == ESP_OK) {
ESP_LOGI("I2CBusManager", "Data read from device at 0x%X on I2C_NUM_%d", address, i2c_port);
notifyObservers(data, length);
} else {
ESP_LOGE("I2CBusManager", "Failed to read from device at 0x%X on I2C_NUM_%d", address, i2c_port);
}
}
};
Using the I2CManagerFactory to Manage Multiple I2C Buses
I2CManagerFactory uses the Factory Pattern to manage the initialization and instantiation of I2C buses, ensuring that each bus is initialized only once.
#include <map>
class I2CManagerFactory {
private:
std::map<i2c_port_t, I2CBusManager*> i2c_buses;
public:
I2CBusManager* getI2CBusManager(i2c_port_t port, int sda, int scl, uint32_t clk_speed = 100000) {
if (i2c_buses.find(port) == i2c_buses.end()) {
i2c_buses[port] = new I2CBusManager(port, sda, scl, clk_speed);
ESP_LOGI("I2CManagerFactory", "Created new I2CBusManager for I2C_NUM_%d", port);
} else {
ESP_LOGW("I2CManagerFactory", "I2C_NUM_%d already initialized", port);
}
return i2c_buses[port];
}
~I2CManagerFactory() {
for (auto& pair : i2c_buses) {
delete pair.second;
}
}
};
Defining the I2CDeviceObserver (Device Observer)
The Observer Pattern allows devices on the I2C bus to be notified when data is updated.
class SensorDevice : public I2CDeviceObserver {
public:
void onData
Received(uint8_t* data, size_t length) override {
// 處理接收到的數據
ESP_LOGI("SensorDevice", "Received data of length %d", length);
}
};
Main Program
Finally, integrate all these components in the main program, scan each I2C bus, and verify connected devices.
extern "C" void app_main() {
I2CManagerFactory factory;
// Initialize two I2C buses
I2CBusManager* bus0 = factory.getI2CBusManager(I2C_NUM_0, GPIO_NUM_21, GPIO_NUM_22);
I2CBusManager* bus1 = factory.getI2CBusManager(I2C_NUM_1, GPIO_NUM_18, GPIO_NUM_19);
// Perform scanning
bus0->scanBus();
bus1->scanBus();
// Create and register a device observer
SensorDevice sensor;
bus0->addObserver(&sensor);
// Example of writing and reading data
uint8_t data_to_write[2] = {0x01, 0x02};
bus0->writeToDevice(0x48, data_to_write, 2);
uint8_t data_to_read[2];
bus0->readFromDevice(0x48, data_to_read, 2);
}
Output
After uploading the code to the ESP32 development board, you can view the log output using the ESP-IDF plugin tool. Below is an example of the expected log output, demonstrating the use of design patterns in the I2C bus management system:
I (100) I2CBusManager: I2C bus initialized on I2C_NUM_0 with SDA:21, SCL:22
I (110) I2CBusManager: I2C bus initialized on I2C_NUM_1 with SDA:18, SCL:19
I (120) I2CBusManager: Starting I2C bus scan on I2C_NUM_0...
I (150) I2CBusManager: Device found at address 0x48 on I2C_NUM_0
I (160) I2CBusManager: I2C bus scan completed on I2C_NUM_0
I (170) I2CBusManager: Starting I2C bus scan on I2C_NUM_1...
I (200) I2CBusManager: I2C bus scan completed on I2C_NUM_1
W (210) I2CManagerFactory: I2C_NUM_0 already initialized
I (220) I2CBusManager: Data written to device at 0x48 on I2C_NUM_0
I (230) I2CBusManager: Data read from device at 0x48 on I2C_NUM_0
I (240) SensorDevice: Received data of length 2
Explanation
I2C BUS Initialization: This shows that the I2C buses, I2C_NUM_0 and I2C_NUM_1, have been initialized with their respective SDA and SCL pins.
I2C BUS Scan: During the scan of the I2C_NUM_0 bus, an I2C device at address 0x48 was detected.
Data Write and Read: Data has been successfully written to the device at address 0x48 on I2C_NUM_0, and data was successfully read from the device.
Observer Receiving Data: After the data is successfully read, the SensorDevice device is notified, and it shows that the data was received.
These log messages clearly show how the I2C bus initialization process, scan results, data read/write operations, and the notification of device data changes are implemented using C++ design patterns. It provides visibility into whether operations on the I2C buses were successful and how device data changes are communicated.
Conclusion
This blog shows how to fully leverage the ESP32’s capabilities by applying design patterns such as the Factory Pattern, Observer Pattern, and other object-oriented design patterns to build a flexible and scalable I2C bus management system. By incorporating these design patterns, we ensure that each step of the process—initialization, device management, and data communication—follows a structured approach, while design patterns like the Observer Pattern facilitate effective data notification and updates. Additionally, using esp_log
allows us to log every operation for better debugging, traceability, and system monitoring.