Oh hey it’s been a while. Let’s pick up where we left off. Here’s the code from last time:

#include "driver/rmt_tx.h"
#include "driver/rmt_rx.h"
#include "freertos/FreeRTOS.h"
#include "esp_log.h"
#include "freertos/idf_additions.h"
#include "freertos/projdefs.h"
 
const char* tag = "Main";
 
#define DHT_IO GPIO_NUM_32
#define DHT_BOUNDARY 50
 
// --- TX Config ---
 
const rmt_tx_channel_config_t tx_chan_config = {
    .gpio_num = DHT_IO,
    .clk_src = RMT_CLK_SRC_DEFAULT,
    .resolution_hz = 1000000,
    .mem_block_symbols = 64,
    .flags.invert_out = false,
    .flags.with_dma = false,
    .trans_queue_depth = 4,
};
 
rmt_copy_encoder_config_t copy_encoder_config = {};
 
const rmt_symbol_word_t start_pulse = {
	.duration0 = 20000,
	.level0 = 0,
	.duration1 = 30,
	.level1 = 1,
};
 
const rmt_transmit_config_t tx_config = {
	.loop_count = 0,
	.flags.eot_level = 1,
};
 
// --- RX Config ---
 
const rmt_rx_channel_config_t rx_chan_config = {
    .gpio_num = DHT_IO,
    .clk_src = RMT_CLK_SRC_DEFAULT,
    .resolution_hz = 1000000,
    .mem_block_symbols = 64,
    .flags.invert_in = false,
    .flags.with_dma = false,
};
 
rmt_receive_config_t rx_config = {
	.signal_range_min_ns = 3000,
	.signal_range_max_ns = 120000,
};
 
typedef struct DHTData {
  uint8_t rh_i;
  uint8_t rh_d;
  uint8_t temp_i;
  uint8_t temp_d;
  uint8_t checksum;
} DHTData;
 
 
DHTData dht_data;
 
void app_main(void) {
	// Create new channel handles
	rmt_channel_handle_t tx_handle = NULL;
	ESP_ERROR_CHECK(rmt_new_tx_channel(&tx_chan_config, &tx_handle));
	rmt_channel_handle_t rx_handle = NULL;
	ESP_ERROR_CHECK(rmt_new_rx_channel(&rx_chan_config, &rx_handle));
 
	// Create encoder
	rmt_encoder_handle_t dht_encoder = NULL;
	ESP_ERROR_CHECK(rmt_new_copy_encoder(&copy_encoder_config, &dht_encoder));
 
	// Create buffer
	rmt_symbol_word_t buffer[50] = {0};
 
	// Enable channel
	ESP_ERROR_CHECK(rmt_enable(tx_handle));
	ESP_ERROR_CHECK(rmt_enable(rx_handle));
 
	// Delay 2ms for sensor to turn on 
	vTaskDelay(pdMS_TO_TICKS(2000));
 
	// Ignore first read
	ESP_ERROR_CHECK(rmt_transmit(tx_handle, dht_encoder, &start_pulse, sizeof(start_pulse), &tx_config));
	ESP_ERROR_CHECK(rmt_receive(rx_handle, buffer, sizeof(buffer), &rx_config));
 
	vTaskDelay(pdMS_TO_TICKS(2000));
 
	memset(buffer, 0, sizeof(buffer));
 
	// Finally, transmit
	ESP_ERROR_CHECK(rmt_transmit(tx_handle, dht_encoder, &start_pulse, sizeof(start_pulse), &tx_config));
 
	// Receive response
	ESP_ERROR_CHECK(rmt_receive(rx_handle, buffer, sizeof(buffer), &rx_config));
 
	vTaskDelay(pdMS_TO_TICKS(2000));
 
	uint8_t bytes[5] = {0};
	for (size_t i = 2; i < 50; i++) {
		ESP_LOGI(tag, "Symbol: %d: L0=%d, D0=%d ticks, L1=%d, D1=%d ticks", i, buffer[i].level0, buffer[i].duration0, buffer[i].level1, buffer[i].duration1);
 
		// Assemble bits
		int byte_index = (i - 2) / 8;
		int bit_index = 7 - ((i - 2) % 8);
 
		if (buffer[i].duration0 > DHT_BOUNDARY) {
			bytes[byte_index] |= (1 << bit_index);
		}
	}
	uint8_t checksum = bytes[0] + bytes[1] + bytes[2] + bytes[3];
	if (checksum != bytes[4]) {
		ESP_LOGE(tag, "Checksum mismatch! Calculated: %d, Received: %d",
						 checksum, bytes[4]);
	}
	// Checksum is valid, populate the struct
	dht_data.rh_i = bytes[0];
	dht_data.rh_d = bytes[1];
	dht_data.temp_i = bytes[2];
	dht_data.temp_d = bytes[3];
	dht_data.checksum = bytes[4];
 
	ESP_LOGI(tag, "Relative Humidity: %d.%d%%", dht_data.rh_i, dht_data.rh_d);
	ESP_LOGI(tag, "Temperature: %d.%dC", dht_data.temp_i, dht_data.temp_d);
 
}

Very ugly! Let’s make better.

FreeRTOS Tasks

Right now, all our logic is dumped into app_main. This is fine for a simple “hello world,” but we want to build a system that can do multiple things at once. The solution? FreeRTOS Tasks. Think of a task as a mini-program that runs independently. We’ll create a dedicated task just for managing the DHT11 sensor.

This cleans up our app_main and lets the sensor logic run in its own function.

// In app_main()
static TaskHandle_t dht_sensor_handle; // A "handle" to refer to our task
xTaskCreate(dht_sensor_task, "dht_sensor_task", 4096, NULL, 10, &dht_sensor_handle);

We create the task with xTaskCreate, giving it the function to run (dht_sensor_task), a name, some memory (stack size), and a priority. Now, all our sensor-related code will live inside the dht_sensor_task function.

Callbacks and Semaphores

Remember how we used vTaskDelay after calling rmt_receive? That’s basically just guessing how long the hardware needs. We can do much better by having the hardware tell us when it’s done.

The RMT Callback

The RMT peripheral is cool enough to support event callbacks. A callback is a function you write that the RMT driver calls for you when a specific event happens—in our case, when it’s finished receiving data.

// This function will be called automatically when RMT receive is done
bool IRAM_ATTR on_revc_done_callback(rmt_channel_handle_t rx_chan,
                                     const rmt_rx_done_event_data_t *edata,
                                     void *user_ctx) {
    BaseType_t high_task_woken = pdFALSE;
    SemaphoreHandle_t sem = (SemaphoreHandle_t)user_ctx;
    xSemaphoreGiveFromISR(sem, &high_task_woken);
    return high_task_woken == pdTRUE;
}
 
// We need to register our callback function
rmt_rx_event_callbacks_t cbs = {
    .on_recv_done = on_revc_done_callback,
};

Think

The IRAM_ATTR attribute tells the compiler to place this function in the MCU’s internal RAM. This makes it run much faster, which is critical for time-sensitive code like an Interrupt Service Routine (ISR). An ISR is special code that interrupts the normal program flow to handle high-priority events, like hardware finishing a task.

Semaphores

So, the callback function runs, but how does it tell our main task that the data is ready? It uses a semaphore. You can think of a semaphore as a signal flag. The ISR raises the flag (xSemaphoreGiveFromISR), and our dht_sensor_task waits for that flag to be raised (xSemaphoreTake).

This is more efficient than vTaskDelay because our task consumes zero CPU time while it’s waiting for the signal (fact check me on that).

Here’s how we set it up and use it in our task:

// In dht_sensor_task:
static SemaphoreHandle_t rmt_done_semaphore;
 
// Create the semaphore before the loop
rmt_done_semaphore = xSemaphoreCreateBinary();
 
// Register the callback and pass the semaphore to it
ESP_ERROR_CHECK(rmt_rx_register_event_callbacks(rx_handle, &cbs, rmt_done_semaphore));
 
// Inside the task's while(1) loop:
// Start receiving data
ESP_ERROR_CHECK(rmt_receive(rx_handle, buffer, sizeof(buffer), &rx_config));
 
// Wait for the ISR to give the semaphore (up to 10 seconds)
if (xSemaphoreTake(rmt_done_semaphore, pdMS_TO_TICKS(10000))) {
    ESP_LOGI(tag, "Reception finished!");
    // ... parse the data like before ...
} else {
    ESP_LOGE(tag, "Reception timed out!");
}

On-Demand Readings and a Proper API

Our sensor task is now efficient, but it’s still just sitting there. We need a way to tell it to actually read from the sensor.

We’ll accomplish this with two things:

  1. Task Notifications: A lightweight way for one task (or app_main) to signal another.
  2. A Getter Function: A clean dht_get_data() function that will act as the public API for our sensor.

Our dht_sensor_task will now sleep until it receives a notification.

// Inside dht_sensor_task's while(1) loop:
while (1) {
    // Sleep here until another task notifies us
    if (ulTaskNotifyTake(pdTRUE, portMAX_DELAY)) {
        // ... do the transmit and receive logic ...
    }
}

And here’s our new and improved getter function, which will handle sending that notification. It also cleverly prevents you from spamming the sensor (the DHT11 needs about 2 seconds between reads).

DHTData *dht_get_data() {
    // Don't read more than once every 2 seconds
    if (esp_timer_get_time() - _last_sensor_read_time < 2000000) {
        ESP_LOGI(tag, "dht_get_data called recently. Returning cached data");
        return &dht_data;
    }
    
    _last_sensor_read_time = esp_timer_get_time();
    
    // Notify the sensor task to wake up and do a reading
    xTaskNotifyGive(dht_sensor_handle);
    
    // Wait for the sensor task to finish and give its own semaphore
    if (xSemaphoreTake(data_ready_semaphore, pdMS_TO_TICKS(2000))) {
        return &dht_data;
    } else {
        // It timed out, just return the old data
        ESP_LOGE(tag, "Failed to get new data - request timed out!");
        return &dht_data;
    }
}

Notice we’re using another semaphore, data_ready_semaphore. The flow is:

  1. dht_get_data() notifies dht_sensor_task to start.
  2. dht_sensor_task does the RMT magic and parses the data.
  3. dht_sensor_task gives the data_ready_semaphore to signal it’s completely done.
  4. dht_get_data() receives this signal and returns the fresh data.

Let’s Get Physical: Adding a Touch Sensor

This is the fun part! We’ll use a capacitive touch sensor (or a simple button) to trigger our dht_get_data() function. We’ll set up a GPIO interrupt. This is similar to the RMT callback—the hardware will interrupt the CPU when the voltage on a pin changes.

Our interrupt handler is super simple. All it does is give a semaphore.

#define TOUCH_SENSOR_GPIO GPIO_NUM_23
static SemaphoreHandle_t touch_sensor_semaphore;
 
// The ISR for our button/touch sensor
void IRAM_ATTR touch_sensor_isr(void *parameter) {
    SemaphoreHandle_t sem = (SemaphoreHandle_t)parameter;
    xSemaphoreGiveFromISR(sem, NULL);
}

Now, in app_main, we set up the GPIO pin to use this interrupt handler and then wait for the semaphore in our main loop.

// In app_main()
touch_sensor_semaphore = xSemaphoreCreateBinary();
 
// Setup the interrupt service and handler
gpio_install_isr_service(ESP_INTR_FLAG_IRAM);
gpio_isr_handler_add(TOUCH_SENSOR_GPIO, touch_sensor_isr, touch_sensor_semaphore);
gpio_intr_enable(TOUCH_SENSOR_GPIO);
gpio_set_intr_type(TOUCH_SENSOR_GPIO, GPIO_INTR_POSEDGE);
 
// The new main loop!
while (1) {
    // Sleep forever until the touch sensor ISR gives the semaphore
    if (xSemaphoreTake(touch_sensor_semaphore, portMAX_DELAY)) {
        DHTData* data = dht_get_data(); // Get that sweet, sweet data
        ESP_LOGI(tag, "Relative Humidity: %d.%d%%", data->rh_i, data->rh_d);
        ESP_LOGI(tag, "Temperature: %d.%dC", data->temp_i, data->temp_d);
    }
}

And that’s it! Our main loop does nothing but wait. When you touch the sensor, the ISR fires, gives the semaphore, the loop wakes up, calls our safe getter function, prints the data, and goes back to sleep. Amazing.