Let’s talk about GPIO (Part 5)

“spooky actions at a distance”

Albert Einstein

Last time we talked about the LED PWM controller in the ESP32, today we’ll talk about the Remote Control Transceiver (RMT) device which is similar but capable of input as well as output. In order to utilize this controller we need some external hardware, in this case the hardware will be two Infrared (IR) LEDs and an IR receiver.

Infrared (IR) communications have been around for a long time and are still a popular form of remote control. Most IR communications consists of a width-modulated pulses on top of a 38KHz carrier wave. The ESP IDF RMT documentation goes into more detail about this modulation technique.

To use the RMT Transmitter (TX) with IR, we use two IR LEDs and a resistor in series directly from the ESP32 output pin. These IR LEDs are chosen to have a forward voltage of 1.2v and a drive current of 20mA, which makes them low power LEDs. Alternative IR LEDs have drive currents up to 200mA, which exceeds the drive capability of a single ESP32 output pin. To drive those an additional external drive amplifier (often a single BC337 transistor) is needed. By using the correct value of resistor we can limit the current through the LEDs to the required 20mA, a value of 45 ohms is suitable ( used 38 ohms as I didn’t have a 45 ohm to hand). We calculate this value from 3.3v – 2 * 1.2v = 0.9v then 0.9v / 20mA = 45. Using 38 ohms will overdrive slightly at 1.2 forward voltage but the variance in the components and the overhead of having a maximum 40mA drive from the ESP32 pin mean that we can tolerate the difference.

For the receiver (a VS1838), we may directly connect the output of the IR receiver component to the pin of the ESP32 we’re going to use for input, and the power (3.3v) and gnd appropriately. Note that the official datasheet for this component also suggests some passive components which help reduce noise, these however are not critical.

So then in order to setup our RMT to transmit, we need to also provide an Encoder which will take some arbitrary data and convert it to the RMT “words” which the RMT will transmit. In our case, for a simple demo, we’re just going to transmit a single byte with no framing information; there would usually be a lead-in and lead-out pulse too.

Setup your ESP IDF as usual (I’m using a LOLIN D32 Pro), and our main code now looks like this:

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "sdkconfig.h"
#include "driver/rmt_tx.h"
#include "driver/rmt_rx.h"

static const char *TAG = "example";

#define IRTX_GPIO 23

#define TRUE 1
#define FALSE 0

rmt_encoder_handle_t ret_encoder;

volatile int tx_cnt=0;

rmt_channel_handle_t tx_chan = NULL;
rmt_tx_channel_config_t tx_chan_config = {
    .clk_src = RMT_CLK_SRC_DEFAULT,       // select source clock
    .gpio_num = IRTX_GPIO,                    // GPIO number
    .mem_block_symbols = 64,          // memory block size, 64 * 4 = 256Bytes
    .resolution_hz = 1 * 1000 * 1000, // 1MHz tick resolution, i.e. 1 tick = 1us
    .trans_queue_depth = 4,           // set the number of transactions that can pend in the background
    .flags.invert_out = false,        // don't invert output signal
    .flags.with_dma = false,          // don't need DMA backend
};

// ISR ISR ISR ISR!
static IRAM_ATTR bool irtx_handler(rmt_channel_handle_t tx_chan, const rmt_tx_done_event_data_t *event, void *user_data) {
    tx_cnt ++;
    return false;
}

rmt_transmit_config_t transmit_config = {
    .loop_count = 0, // no loop
};

static void blink_irtx() {
    // save the received RMT symbols
    // rmt_rx_done_event_data_t rx_data;
    // ready to receive
    char* payload = "I";
    ESP_ERROR_CHECK(rmt_transmit(tx_chan, ret_encoder, payload, 1, &transmit_config));
}

void app_main(void)
{
    ESP_ERROR_CHECK(rmt_new_tx_channel(&tx_chan_config, &tx_chan));

    rmt_carrier_config_t tx_carrier_cfg = {
        .duty_cycle = 0.33,                 // duty cycle 33%
        .frequency_hz = 38000,              // 38KHz
        .flags.polarity_active_low = false, // carrier should modulated to high level
    };
//  modulate carrier to TX channel
    ESP_ERROR_CHECK(rmt_apply_carrier(tx_chan, &tx_carrier_cfg));

    rmt_tx_event_callbacks_t tbs = {
        .on_trans_done = irtx_handler
    };
    ESP_ERROR_CHECK(rmt_tx_register_event_callbacks(tx_chan, &tbs, NULL));

    ESP_ERROR_CHECK(rmt_enable(tx_chan));

    rmt_bytes_encoder_config_t config = {
        .bit0={
            .duration0=560ULL * tx_chan_config.resolution_hz / 1000000,
            .level0=1,
            .duration1=560ULL * tx_chan_config.resolution_hz / 1000000,
            .level1=0
        },
        .bit1={
            .duration0=560ULL * tx_chan_config.resolution_hz / 1000000,
            .level0=1,
            .duration1=1690ULL * tx_chan_config.resolution_hz / 1000000,
            .level1=0
        },
        .flags.msb_first=FALSE,
    };

    ESP_ERROR_CHECK(rmt_new_bytes_encoder(&config, &ret_encoder));

    while (1) {
       
        ESP_LOGI(TAG, "TX cnt %d", tx_cnt);
        blink_irtx();

        vTaskDelay(CONFIG_BLINK_PERIOD / portTICK_PERIOD_MS);
    }
}

This example just sends a stream of ‘I’ bytes, one per BLINK_PERIOD. The ‘bytes’ encoder needs to know the definitions of two width modulated pulses, one to represent a one bit in the byte and one to represent a zero bit.

If we now hook up our regular LED and our IR receiver, we can update the code to toggle the LED on and of as the ‘i’ bytes are received. Note that we need to re-init the receiver after each transaction.

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "sdkconfig.h"
#include "driver/rmt_tx.h"
#include "driver/rmt_rx.h"

static const char *TAG = "example";

/* Use project configuration menu (idf.py menuconfig) to choose the GPIO to blink,
   or you can edit the following line and set a number here.
*/
#define BLINK_GPIO 5
#define IRTX_GPIO 23
#define IRRX_GPIO 21

#define TRUE 1
#define FALSE 0

#include "esp_event.h"

rmt_encoder_handle_t ret_encoder;

volatile int tx_cnt=0;
volatile int rx_cnt=0;

ESP_EVENT_DECLARE_BASE(APP_EVENT);

typedef enum {
    APP_EVENT_EDGE,
} app_event_t;

extern esp_event_loop_handle_t app_event_loop;

ESP_EVENT_DEFINE_BASE(APP_EVENT);

IRAM_ATTR esp_event_loop_handle_t app_event_loop;

rmt_symbol_word_t raw_symbols[64]; // 64 symbols should be sufficient for a standard NEC frame

static uint8_t s_led_state = 0;
rmt_channel_handle_t rx_chan = NULL;
rmt_rx_channel_config_t rx_chan_config = {
    .clk_src = RMT_CLK_SRC_DEFAULT,       // select source clock
    .resolution_hz = 1 * 1000 * 1000, // 1MHz tick resolution, i.e. 1 tick = 1us
    .mem_block_symbols = 64,          // memory block size, 64 * 4 = 256Bytes
    .gpio_num = IRRX_GPIO,                    // GPIO number
    .flags.invert_in = true,         // don't invert input signal
    .flags.with_dma = false,          // don't need DMA backend
};

rmt_channel_handle_t tx_chan = NULL;
rmt_tx_channel_config_t tx_chan_config = {
    .clk_src = RMT_CLK_SRC_DEFAULT,       // select source clock
    .gpio_num = IRTX_GPIO,                    // GPIO number
    .mem_block_symbols = 64,          // memory block size, 64 * 4 = 256Bytes
    .resolution_hz = 1 * 1000 * 1000, // 1MHz tick resolution, i.e. 1 tick = 1us
    .trans_queue_depth = 4,           // set the number of transactions that can pend in the background
    .flags.invert_out = false,        // don't invert output signal
    .flags.with_dma = false,          // don't need DMA backend
};

static void blink_led(void)
{
     gpio_set_level(BLINK_GPIO, s_led_state);
}

static void configure_led(void)
{
    gpio_reset_pin(BLINK_GPIO);
    /* Set the GPIO as a push/pull output */
    gpio_set_direction(BLINK_GPIO, GPIO_MODE_OUTPUT);
    gpio_set_level(BLINK_GPIO,0);
}

static void handle_edge(void *event_handler_arg, esp_event_base_t event_base, int32_t event_id, void *event_data) {
    //  ESP_LOGI(TAG, "EDGE!");
    blink_led();
    ESP_LOGI(TAG,"Receive!");
}

// ISR ISR ISR ISR!
static IRAM_ATTR bool irrx_handler(rmt_channel_handle_t rx_chan, const rmt_rx_done_event_data_t *event, void *user_data) {   
    rx_cnt++;
    ESP_ERROR_CHECK(esp_event_post_to(app_event_loop, APP_EVENT, APP_EVENT_EDGE, NULL, 0, (TickType_t)10));
    return false;
}

// ISR ISR ISR ISR!
static IRAM_ATTR bool irtx_handler(rmt_channel_handle_t tx_chan, const rmt_tx_done_event_data_t *event, void *user_data) {
    tx_cnt ++;
    return false;
}

rmt_transmit_config_t transmit_config = {
    .loop_count = 0, // no loop
};

// the following timing requirement is based on NEC protocol
rmt_receive_config_t receive_config = {
    .signal_range_min_ns = 1250,     // the shortest duration for NEC signal is 560us, 1250ns < 560us, valid signal won't be treated as noise
    .signal_range_max_ns = 12000000, // the longest duration for NEC signal is 9000us, 12000000ns > 9000us, the receive won't stop early
};

static void blink_irtx() {  
    // Re-init the receiver
    ESP_ERROR_CHECK(rmt_receive(rx_chan, raw_symbols, sizeof(raw_symbols), &receive_config));
    // Transmit the toggle command via IR
    char* payload = "I";
    ESP_ERROR_CHECK(rmt_transmit(tx_chan, ret_encoder, payload, 1, &transmit_config));
}

void app_main(void)
{
    configure_led();

    // Create the application event loop
    esp_event_loop_args_t event_loop_args = {
        .queue_size = 5,
        .task_name = NULL
    };
    ESP_ERROR_CHECK(esp_event_loop_create(&event_loop_args, &app_event_loop));
    ESP_ERROR_CHECK(esp_event_handler_register_with(app_event_loop, APP_EVENT, APP_EVENT_EDGE, handle_edge, NULL));

    ESP_ERROR_CHECK(rmt_new_tx_channel(&tx_chan_config, &tx_chan));
    ESP_ERROR_CHECK(rmt_new_rx_channel(&rx_chan_config, &rx_chan));

    rmt_carrier_config_t tx_carrier_cfg = {
        .duty_cycle = 0.33,                 // duty cycle 33%
        .frequency_hz = 38000,              // 38KHz
        .flags.polarity_active_low = false, // carrier should modulated to high level
    };
//  modulate carrier to TX channel
    ESP_ERROR_CHECK(rmt_apply_carrier(tx_chan, &tx_carrier_cfg));

    rmt_tx_event_callbacks_t tbs = {
        .on_trans_done = irtx_handler
    };
    ESP_ERROR_CHECK(rmt_tx_register_event_callbacks(tx_chan, &tbs, NULL));

    rmt_rx_event_callbacks_t cbs = {
        .on_recv_done = irrx_handler
    };
    ESP_ERROR_CHECK(rmt_rx_register_event_callbacks(rx_chan, &cbs, NULL));

    ESP_ERROR_CHECK(rmt_enable(tx_chan));
    ESP_ERROR_CHECK(rmt_enable(rx_chan));

    rmt_bytes_encoder_config_t config = {
        .bit0={
            .duration0=560ULL * tx_chan_config.resolution_hz / 1000000,
            .level0=1,
            .duration1=560ULL * tx_chan_config.resolution_hz / 1000000,
            .level1=0
        },
        .bit1={
            .duration0=560ULL * tx_chan_config.resolution_hz / 1000000,
            .level0=1,
            .duration1=1690ULL * tx_chan_config.resolution_hz / 1000000,
            .level1=0
        },
        .flags.msb_first=FALSE,
    };

    ESP_ERROR_CHECK(rmt_new_bytes_encoder(&config, &ret_encoder));

    while (1) {
        ESP_LOGI(TAG, "Turning the LED %s!", s_led_state == true ? "ON" : "OFF");
        ESP_LOGI(TAG, "TX cnt %d rx cnt %d", tx_cnt, rx_cnt);
        blink_irtx();

        /* Toggle the LED state */
        s_led_state = !s_led_state;
        ESP_ERROR_CHECK(esp_event_loop_run(app_event_loop, pdMS_TO_TICKS(CONFIG_BLINK_PERIOD)));
        // vTaskDelay(CONFIG_BLINK_PERIOD / portTICK_PERIOD_MS);
    }
}

Now we have an RMT receiver (RX) channel setup. Those of you with smart eyes will notice that there is NOT a carrier wave configuration for the receiver. The RMT supports this however the external IR receiver I’m using already contains a carrier wave decoder so the ESP32 input pin receives just the width-modulated pulses not the carrier wave. Note that using both the RMT TX and RX on the same ESP32 is certainly possible as per the above example, but a more common use case would use one or the other, e.g. an IR remote control would use just the transmitter, and a controlled device would use the receiver.

There are two interrupts setup, one when the transmit finishes which just counts how many toggles we have sent, and one when we receive a transmission which toggles the blue LED. We use an event loop to decouple the Interrupt Service Routine as they should be as quick as possible.

In a real receiver app, you would usually want to parse the token stream for a valid command. There is an example of this in the ESP IDF.