ESP-IDF: A case for skipping Arduino

I have a huge fascination with little MCUs. Something about the exposed pins, the ability to affect the physical world, and the simplicity of it all is just neat. While Arduino has been around for a long time, I have never actually used it. After trying to get a more-than-blinking-a-led project off the ground I soon ran into the limitations. I have no doubt that if I was playing with some robotics it would be just fine. However I wanted to create something that worked as a BLE keyboard (a macropad).

I quickly found that the Arduino libraries were not up to the task for my specific board (an ESP32C6). The incredibly broad support meant that it just wasn’t doing what I wanted. After an hour of debugging errors with third party code not compiling I decided to just jump into ESP-IDF.

I’m not going to lie, embedded tooling had a learning curve for me (although some might just call it a pain). After playing around with esp-rs previously (specifcally the non-idf rust native bindings for esp32), using raw esp-idf was a bit of a beast. The deluge of python scripts, components, toolchains, etc. was a bit overwhelming. However, it’s not too bad to get a simple BLE keyboard up and running in a few hours. ESP-IDF has more examples and documentation than you could imagine and it’s pretty copy/paste to get something working.

Let’s quickly go over the quick and dirty way I got this working. There are three main components:

  1. Listening for the button presses
  2. Updating the LED
  3. Sending the keypress

Each one of these maps very nicely to FreeRTOS tasks.

bool _enabled;
bool* enabled = &_enabled;
*enabled = false;
keyboard_task_params_t boop = {
    .sec_conn = &sec_conn,
    .hid_conn_id = &hid_conn_id,
    .enabled = enabled,
};
watch_gpio_task_params_t watcha = {
    .pin = GPIO_NUM_9,
    .callback = &update_bool,
    .data = enabled,
};
device_state_task_params_t device_state = {
    .enabled = enabled,
    .connected = &sec_conn,
};
xTaskCreate(&watch_gpio_task, "watch_gpio_task", 2048, &watcha, 4, NULL);
xTaskCreate(&hid_keyboard_task, "hid_keyboard_task", 2048, &boop, 5, NULL);
xTaskCreate(&device_state_task, "device_state_task", 2048, &device_state, 2, NULL);

Can you imagine trying to cram all the timing logic into a single loop to listen for a button press, blink an LED, and run the actual logic? Absolutely not, FreeRTOS makes this a breeze. I won’t go too deep into the code, but if it isn’t obvious this is some very race-condition prone code. I didn’t bother with mutexes, queues, or anything like that. There are just two bools, one that indicates whether we’re sending the keystrokes, and one for whether we’re connected to a device.

The devics state task is just showing a blue light to indicate it’s serching for a device, a green light to indicate it’s connected, and blinking orange if it’s sending keystrokes.

The watch_gpio_task though is a bit more interesting. It’s akin to the keypad library in CircuitPython. I swear, I tried to find some built-in functionality to match the keypad library. I didn’t see one but I’m sure there’s some combination in FreeRTOS/ESP-IDF that accomplishes the “wait for a button press” functionality. I just didn’t see it. The last_state variable is useless, but hey, the project WORKS. So I’m not going to touch it.

void watch_gpio_task(void *pvParameters)
{
    watch_gpio_task_params_t *boop = (watch_gpio_task_params_t *)pvParameters;
    gpio_num_t pin = GPIO_NUM_9;
    void (*callback)(void *any, int level) = boop->callback;
    void *data = boop->data;
    WAIT(3000);
    gpio_reset_pin(pin);
    gpio_set_direction(pin, GPIO_MODE_INPUT);
    int last_state = 1;
    while (1)
    {
        int state = gpio_get_level(pin);
        if (state)
        {
            WAIT(50);
        }
        else
        {
            while (!gpio_get_level(pin))
            {
                WAIT(30);
            }
            callback(data, last_state);
        }
    }
}