Working with sensors
To relate to the physical world, we use sensors. For example, a button or a pot are sensors, but when we need to measure different phenomena—let's say temperature—we can use a temperature sensor with a more advanced communication interface rather than a simple GPIO or ADC interface. In this section, we'll cover plenty of sensors with different communication interfaces to get familiar with those interfaces. Let's start with a popular temperature and humidity sensor, DHT11.
Reading ambient temperature and humidity with DHT11
DHT11 is a basic temperature and humidity sensor with a very low price tag. The operational voltage is between 3 and 5 V, so we can use it by directly connecting to our ESP32 without a need for a level shifter. The temperature range is 0-50°C, with ±2°C accuracy and 1°C resolution. We can also use it to measure humidity between 20% and 90% with ±5% accuracy. It already comes calibrated, so we don't need to worry about calibration. It also incorporates a simple 8-bit processor, which enables it to implement a basic single-wire serial communication protocol. The following figure shows a DHT11 module that you can find online:
It has three pins for ground (GND), Voltage Common Collector (VCC), and signal. We use that signal pin to communicate with it. Let's use it in a simple example. The hardware components are listed here:
- DHT11 module
- Active buzzer module
I have a sensor kit from ELEGOO with many different types of sensors. These two modules come with it, but you can use any such modules you have to hand. You can see the following Fritzing diagram for connections:
We will use an external library for DHT11 communication. This is included in the book repository and can be found at this link: https://github.com/PacktPublishing/Internet-of-Things-with-ESP32/tree/main/common/esp-idf-lib.
The original library is here: https://github.com/UncleRus/esp-idf-lib. It is a great work by many developers for the community.
In this example, we are going to read temperature and humidity values from the DHT11 module, and if any of those values exceed the threshold, the buzzer will start an alarm sound pattern.
After creating the PlatformIO project, we first need to edit the platformio.ini
file to set the serial monitor baud rate and the external library, as follows:
monitor_speed = 115200 lib_extra_dirs = ../../esp-idf-lib/components
The lib_extra_dirs
option tells PlatformIO where to search for external libraries. The DHT11 driver is located under this folder. Now, we can proceed with the code in the main.c
source file, as follows:
#include <stdio.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> #include <dht.h> #include "driver/gpio.h" #define DHT11_PIN 17 #define BUZZER_PIN 18 #define BUZZER_PIN_SEL (1ULL << BUZZER_PIN) #define HUM_THRESHOLD 800 #define TEMP_THRESHOLD 250 static void init_hw(void) { Â Â Â Â gpio_config_t io_conf; Â Â Â Â io_conf.mode = GPIO_MODE_OUTPUT; Â Â Â Â io_conf.pin_bit_mask = BUZZER_PIN_SEL; Â Â Â Â io_conf.intr_type = GPIO_INTR_DISABLE; Â Â Â Â io_conf.pull_down_en = 0; Â Â Â Â io_conf.pull_up_en = 0; Â Â Â Â gpio_config(&io_conf); }
We include the dht.h
header file to use DHT11. driver/gpio.h
is only for the buzzer configuration. DHT11 returns 10 times of the ambient values it measures, so HUM_THRESHOLD
and TEMP_THRESHOLD
values denote 10 times of the actual threshold values for easy comparison—so, 80% of humidity and 25°C temperature are the threshold values in our program. In the init_hw
function, we only initialize BUZZER_PIN
for alarm output. We do not have any specific DHT11 initialization functions in the library.
Next comes a function to generate an alarm sound as in the following snippet:
static void beep(void *arg) { Â Â Â Â int cnt = 2 * (int)arg; Â Â Â Â bool state = true; Â Â Â Â for (int i = 0; i < cnt; ++i, state = !state) Â Â Â Â { Â Â Â Â Â Â Â Â gpio_set_level(BUZZER_PIN, state); Â Â Â Â Â Â Â Â vTaskDelay(100 / portTICK_PERIOD_MS); Â Â Â Â } Â Â Â Â vTaskDelete(NULL); }
beep
simply sets the BUZZER_PIN
level to on and off every 100 ms for a given number of times in the function parameter. This function will be called from a FreeRTOS task every time a threshold is exceeded, and it deletes the task when the job is finished.
Then, we check the alarm state. The code is shown in the following snippet:
static int16_t temperature; static bool temp_alarm = false; static int16_t humidity; static bool hum_alarm =false; static void check_alarm(void) { Â Â Â Â bool is_alarm = temperature >= TEMP_THRESHOLD; Â Â Â Â bool run_beep = is_alarm && !temp_alarm; Â Â Â Â temp_alarm = is_alarm; Â Â Â Â if (run_beep) Â Â Â Â { Â Â Â Â Â Â Â Â xTaskCreate(beep, "beep", configMINIMAL_STACK_SIZE, (void *)3, 5, NULL); Â Â Â Â Â Â Â Â return; Â Â Â Â } Â Â Â Â is_alarm = humidity >= HUM_THRESHOLD; Â Â Â Â run_beep = is_alarm && !hum_alarm; Â Â Â Â hum_alarm = is_alarm; Â Â Â Â if (run_beep) Â Â Â Â { Â Â Â Â Â Â Â Â xTaskCreate(beep, "beep", configMINIMAL_STACK_SIZE, (void *)2, 5, NULL); Â Â Â Â } }
We have four global variables to hold ambient readings and alarm states. The check_alarm
function checks for a new alarm for both temperate and humidity, and if there is one, it then creates a new beep
task by calling the xTaskCreate
function of FreeRTOS. It takes six parameters for the following:
- Function to be called.
- Task name.
- Task local stack size in bytes. We need to reserve enough memory to run a task properly.
- Function parameter(s).
- Task priority.
- A pointer for the task handle, if needed. For this example, we don't need to keep track of beep tasks—just fire and forget.
We will discuss the FreeRTOS functions in detail in the next chapter. Let's continue with the app_main
function, as follows:
int app_main() {     init_hw();     while (1)     {         if (dht_read_data(DHT_TYPE_DHT11, (gpio_num_t)DHT11_PIN, &humidity, &temperature) == ESP_OK)         {             printf("Humidity: %d%% Temp: %dC\n", humidity / 10, temperature / 10);             check_alarm();         }         else         {             printf("Could not read data from sensor\n");         }         vTaskDelay(2000 / portTICK_PERIOD_MS);     } }
In the app_main
function, we do initialization with init_hw
and then start a while
loop to read from the DHT11 sensor periodically. dht_read_data
is the function to do that. It needs a DHT sensor type (which is DHT_TYPE_DHT11
in our project), the GPIO pin that the sensor is connected to, and the addresses of the variables to write readings. It returns ESP_OK
if all goes well and we print the values on the serial port, then check for an alarm case. Lastly, we set a 2-second delay between readings.
Important note
The sampling rate of DHT11 should not exceed 1 Hz (that is, one reading per second). Otherwise, the sensor gets hot and the readings will be inaccurate.
DHT11 is a low-cost, easy-to-use temperature sensor but when we need a higher resolution, we can use DS18B20, as in the following example.
Using DS18B20 as temperature sensor
DS18B20 is a high-resolution, programmable thermometer from Maxim Integrated. Its measurement range is -55°C to +125°C and it provides ±0.5°C accuracy between -10°C and +85°C, which makes it a strong candidate for many types of applications. DS18B20 uses a 1-Wire bus system and communication protocol with 64-bit addressing so that multiple DS18B20s can share the same communication line.
It is manufactured in different packages. The following figure shows a DS18B20 sensor in a TO-92 package:
In this example, we simply scan a line to find out connected DS18B20 sensors and then query them to get temperature readings. The hardware components are listed as follows:
- DS18B20 sensor. I will use a single DS18B20 module from my ELEGOO kit, but you can connect many sensors on the same bus if you have them.
- A 4.7 kΩ pull-up resistor for the data leg of the sensor.
A driver exists in the same external library from the previous example. We are going to include it to drive the sensor. Here is the link for the library: https://github.com/PacktPublishing/Internet-of-Things-with-ESP32/tree/main/common/esp-idf-lib.
You can see the connections in the following Fritzing diagram:
After setting up the circuitry, we can continue with the code. First, we edit the platformio.ini
file and add the external library path, as follows:
monitor_speed = 115200 lib_extra_dirs = ../../esp-idf-lib/components
Now, we can write our application in main.c
, like this:
#include <stdio.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> #include <ds18x20.h> #define SENSOR_PIN 21 #define MAX_SENSORS 8 static ds18x20_addr_t addrs[MAX_SENSORS]; static int sensor_count = 0; static float temps[MAX_SENSORS];
We include the ds18x20.h
header file for the driver functions and type definitions. For instance, ds18x20_addr_t
is the type for addressing, which is simply a 64-bit unsigned integer defined in this header file.
In our example, we allocate arrays of size 8 to hold addresses and temperature readings. Then, the hardware initialization is as follows:
static void init_hw(void) { Â Â Â Â while (sensor_count == 0) Â Â Â Â { Â Â Â Â Â Â Â Â sensor_count = ds18x20_scan_devices((gpio_num_t)SENSOR_PIN, addrs, MAX_SENSORS); Â Â Â Â Â Â Â Â vTaskDelay(1000 / portTICK_PERIOD_MS); Â Â Â Â } Â Â Â Â if (sensor_count > MAX_SENSORS) Â Â Â Â { Â Â Â Â Â Â Â Â sensor_count = MAX_SENSORS; Â Â Â Â } }
In the init_hw
function, we scan the 1-wire bus for sensors by calling ds18x20_scan_devices
. This takes the GPIO pin where the sensors are connected and fills the addrs
array with sensor addresses. Now, we can define the app_main
function as illustrated in the following code snippet:
void app_main() { Â Â Â Â init_hw(); Â Â Â Â while (1) Â Â Â Â { Â Â Â Â Â Â Â Â ds18x20_measure_and_read_multi((gpio_num_t)SENSOR_PIN, addrs, sensor_count, temps); Â Â Â Â Â Â Â Â for (int i = 0; i < sensor_count; i++) Â Â Â Â Â Â Â Â { Â Â Â Â Â Â Â Â Â Â Â Â printf("sensor-id: %08x temp: %fC\n", (uint32_t)addrs[i], temps[i]); Â Â Â Â Â Â Â Â } Â Â Â Â Â Â Â Â vTaskDelay(1000 / portTICK_PERIOD_MS); Â Â Â Â } }
The app_main
function contains a while
loop where we read from the sensors. The name of the library function to query temperature values from the sensors is ds18x20_measure_and_read_multi
, and this takes the GPIO pin where all sensors are connected, along with the addresses and the array to store readings.
Tip
If you have a single DS18B20 sensor connected to your ESP32 then you can use another function from this library, named ds18x20_read_temperature
, with the sensor address of ds18x20_ANY
. This eliminates the need of a bus scan to discover addresses.
Sensing light with TSL2561
If we want to measure ambient light level, then we can use TSL2561 from AMS. It provides illuminance in lux over the Inter-Integrated Circuit (I2C or IIC) interface to the connected microcontroller with 16-bit resolution. There are many example use cases, such as automatic keyboard illumination where optimum viewing condition is needed.
I2C is another serial communication bus that supports multiple devices on the same line. Devices on the bus use 7-bit addressing. Two lines are needed for the I2C interface: clock (CLK) and serial data (SDA). The master device provides the clock to the bus.
An example light sensor is shown in the following figure:
In this example, we are going to set the LED level according to the ambient light-level data coming from a TSL2561 module—a high level of ambient light, low LED duty, and vice versa. The hardware components are listed here:
- A TSL2561 module (for example, from Adafruit)
- An LED
- 330 ohm resistor
The following Fritzing diagram shows the connections in our setup:
Let's code the application. As always, we first update platformio.ini
for any additional settings, as follows:
monitor_speed = 115200 lib_extra_dirs = ../../esp-idf-lib/components build_flags = -DCONFIG_I2CDEV_TIMEOUT=100000
This time, in addition to the external library path, we add a CONFIG_I2CDEV_TIMEOUT
definition as a build flag required by the TSL2561 library. After updating the platformio.ini
file, we can continue with our program, as follows:
#include <stdio.h> #include <string.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> #include <tsl2561.h> #include "driver/ledc.h" #define SDA_GPIO 21 #define SCL_GPIO 22 #define ADDR TSL2561_I2C_ADDR_FLOAT static tsl2561_t light_sensor; #define LEDC_GPIO 18 static ledc_channel_config_t ledc_channel;
The tsl2561.h
header file contains all definitions and functionality to drive a TSL2561 sensor. We use GPIO21 as the I2C data pin and GPIO22 as the I2C clock, as designated pins of the devkit board. The I2C address is defined as TSL2561_I2C_ADDR_FLOAT
and we access the sensor through a variable of type tsl2561_t
.
Important note
A TSL2561 sensor can have three different I2C addresses. It is determined by the status of the ADDR-SEL pin of the sensor, and can be connected to either VCC, GND, or left float.
Then, we define the hardware initialization function as in the following code snippet:
static void init_hw(void) { Â Â Â Â i2cdev_init(); Â Â Â Â memset(&light_sensor, 0, sizeof(tsl2561_t)); Â Â Â Â light_sensor.i2c_dev.timeout_ticks = 0xffff / portTICK_PERIOD_MS; Â Â Â Â tsl2561_init_desc(&light_sensor, ADDR, 0, SDA_GPIO, SCL_GPIO); Â Â Â Â tsl2561_init(&light_sensor); Â Â Â Â ledc_timer_config_t ledc_timer = { Â Â Â Â Â Â Â Â .duty_resolution = LEDC_TIMER_10_BIT, Â Â Â Â Â Â Â Â .freq_hz = 1000, Â Â Â Â Â Â Â Â .speed_mode = LEDC_HIGH_SPEED_MODE, Â Â Â Â Â Â Â Â .timer_num = LEDC_TIMER_0, Â Â Â Â Â Â Â Â .clk_cfg = LEDC_AUTO_CLK, Â Â Â Â }; Â Â Â Â ledc_timer_config(&ledc_timer); Â Â Â Â ledc_channel.channel = LEDC_CHANNEL_0; Â Â Â Â ledc_channel.duty = 0; Â Â Â Â ledc_channel.gpio_num = LEDC_GPIO; Â Â Â Â ledc_channel.speed_mode = LEDC_HIGH_SPEED_MODE; Â Â Â Â ledc_channel.hpoint = 0; Â Â Â Â ledc_channel.timer_sel = LEDC_TIMER_0; Â Â Â Â ledc_channel_config(&ledc_channel); }
The initialization of TSL2561 requires several steps. First, we initialize the I2C bus with the i2cdev_init
function and set its timeout_ticks
field to 0xffff
in ms. Then, we call the tsl2561_init_desc
and tsl2561_init
functions to initialize the TSL2561 sensor itself. The tsl2561_init_desc
function takes the sensor address and I2C pins as parameters to communicate with the sensor. Next, we initialize the LED pin as PWM output (ledc_channel_config_t
) in order to control its brightness.
We also need a function to set the LED brightness. Here it is:
static void set_led(uint32_t lux) { Â Â Â Â uint32_t duty = 1023; Â Â Â Â if (lux > 50) Â Â Â Â { Â Â Â Â Â Â Â Â duty = 0; Â Â Â Â } Â Â Â Â else if (lux > 20) Â Â Â Â { Â Â Â Â Â Â Â Â duty /= 2; Â Â Â Â } Â Â Â Â ledc_set_duty(ledc_channel.speed_mode, ledc_channel.channel, duty); Â Â Â Â ledc_update_duty(ledc_channel.speed_mode, ledc_channel.channel); }
In the set_led
function, we change the brightness of the LED according to its lux
parameter. For lux
levels higher than 50
, we set the duty
variable to 0
so that the LED turns off. If the lux level is more than 20
, then half duty, else full duty for full brightness of the LED. You can play with the lux
value comparisons according to your ambient light to get a better result.
Lastly, we define the app_main
function. The code is shown in the following snippet:
void app_main() { Â Â Â Â init_hw(); Â Â Â Â uint32_t lux; Â Â Â Â while (1) Â Â Â Â { Â Â Â Â Â Â Â Â vTaskDelay(500 / portTICK_PERIOD_MS); Â Â Â Â Â Â Â Â if (tsl2561_read_lux(&light_sensor, &lux) == ESP_OK) Â Â Â Â Â Â Â Â { Â Â Â Â Â Â Â Â Â Â Â Â printf("Lux: %u\n", lux); Â Â Â Â Â Â Â Â Â Â Â Â set_led(lux); Â Â Â Â Â Â Â Â } Â Â Â Â } }
The while
loop of the app_main
function is where we read from the light sensor periodically and set the LED brightness. The tsl2561_read_lux
function sets the lux
parameter value when called.
That's it. We can add light-sensing capability to our IoT device with a TSL2561 sensor, as we did in this example. The next example uses another popular I2C device: BME280.
Employing BME280 in your project
BME280 is a high-resolution temperature, humidity, and barometric pressure sensor from Bosch. The temperature range is -40°C-85 °C with ±1.0 °C accuracy, the humidity range is 0-100% with ±3% accuracy, and the pressure range is 300-1100 hectopascal pressure unit (hPa) with ±1.0 hPa accuracy. It supports both Serial Peripheral Interface (SPI) and I2C communication interfaces. It can be operated in three different modes, as follows:
- Sleep mode
- Normal mode
- Forced mode
In sleep mode, measurements are disabled and its power consumption is as low as 0.1 μA. Therefore, it is a good option for battery-operated devices such as smart watches since its power mode can be programmatically controllable.
There are many BME280 modules on the market, so you can buy and use any of them in your projects. A BME280 module is shown in the following figure:
In this example, we are going to read from BME280 and print it on the serial monitor of PlatformIO. The only hardware component is a BME280 module. The following Fritzing diagram shows the connections:
We are ready to continue with the application. After creating the project, we need to update the platformio.ini
file, as follows:
[env:az-delivery-devkit-v4] platform = espressif32 board = az-delivery-devkit-v4 framework = espidf monitor_speed = 115200 lib_extra_dirs = Â Â Â Â ../../esp-idf-lib/components build_flags = Â Â Â Â -DCONFIG_I2CDEV_TIMEOUT=100000
CONFIG_I2CDEV_TIMEOUT
is required by the sensor library, and we define it as a build flag to be passed to the compiler.
Let's code the application in main.c
, as follows:
#include <stdio.h> #include <freertos/FreeRTOS.h> #include <freertos/task.h> #include <bmp280.h> #include <string.h> #define SDA_GPIO 21 #define SCL_GPIO 22 static bmp280_t temp_sensor;
The header file for BME280 is bmp280.h
. The device type is bmp280_t
, which is defined in this header. SDA and Serial Clock (SCL) I2C bus lines are connected to GPIO21 and GPIO22 respectively. Next, we initialize the hardware, as illustrated in the following code snippet:
static void init_hw(void) { Â Â Â Â i2cdev_init(); Â Â Â Â memset(&temp_sensor, 0, sizeof(bmp280_t)); Â Â Â Â temp_sensor.i2c_dev.timeout_ticks = 0xffff / portTICK_PERIOD_MS; Â Â Â Â bmp280_params_t params; Â Â Â Â bmp280_init_default_params(¶ms); Â Â Â Â bmp280_init_desc(&temp_sensor, BMP280_I2C_ADDRESS_0, 0, SDA_GPIO, SCL_GPIO); Â Â Â Â bmp280_init(&temp_sensor, ¶ms); }
In the hardware initialization function, we first initialize the I2C bus with i2cdev_init
, and then the sensor. The bmp280_init_desc
function sets the I2C parameters of the BME280 module by passing the address and the I2C pins. Then, the default parameters are set in bmp280_init
—these are the normal power mode operation and the 4 Hz sampling rate (one reading in 250 ms) in this library.
Important note
There are two I2C addressing options for BME280, for when the SDO pin of BME280 is connected to GND and for when it is connected to VCC. In this example, it is connected to GND.
Now, we can code the app_main
function, as follows:
void app_main() { Â Â Â Â init_hw(); Â Â Â Â float pressure, temperature, humidity; Â Â Â Â while (1) Â Â Â Â { Â Â Â Â Â Â Â Â vTaskDelay(500 / portTICK_PERIOD_MS); Â Â Â Â Â Â Â Â if (bmp280_read_float(&temp_sensor, &temperature, &pressure, &humidity) == ESP_OK) Â Â Â Â Â Â Â Â { Â Â Â Â Â Â Â Â Â Â Â Â printf("%.2f Pa, %.2f C, %.2f %%\n", pressure, temperature, humidity); Â Â Â Â Â Â Â Â } Â Â Â Â } }
In the app_main
function, we simply read values from the BME280 module by calling bmp280_read_float
, and print on the serial monitor every 500 ms.
This was the last example for sensor devices. In the next section, we are going to use actuators.