encoder: HW timer-based driver for incremental rotary encoders

The driver polls the pins of an incremental encoder with a periodic esp_timer, so the pins do not have to be interrupt capable and any number of encoders work at the same time. Every encoder has its own timer and its own lock, so the encoders do not disturb each other.

The polling interval is not a part of the configuration: it is fixed at build time by CONFIG_RE_INTERVAL_US. The same value is the resolution of the button timings, which are counted in polls, so a value that can be changed per encoder would make a long press depend on the caller. One full detent of the encoder takes four polls, and the button dead time, the long press threshold and the acceleration use the same interval.

The events are reported from the timer task. The callback has to return quickly, because every other timer of the system waits for it, and it must not call back into the driver: rotary_encoder_delete(), rotary_encoder_enable_acceleration() and rotary_encoder_disable_acceleration() refuse to run inside the callback with ESP_ERR_INVALID_STATE. Pass the events to another task through a queue or a task notification instead.

With ESP-IDF 6.1 and newer, rotary_encoder_delete() waits for a callback that is already running before it frees the handle. Older versions have no way to wait: the timer stops dispatching events and the lock orders the callbacks that have started, but a callback that the timer task has dispatched and that has not taken the lock yet can still run after the handle is freed, so on those versions delete the encoder only when the events are no longer expected.

Defines

RE_DEFAULT_BTN_PRESSED_LEVEL
RE_DEFAULT_ENABLE_INTERNAL_PULLUP
ROTARY_ENCODER_DEFAULT_CONFIG()

Typedefs

typedef struct rotary_encoder *rotary_encoder_handle_t
typedef void (*rotary_encoder_event_cb_t)(const rotary_encoder_event_t *event, void *ctx)

Callback type for encoder events.

The callback runs in the task of the esp_timer, with the lock of the encoder held, so it has to return quickly: a slow callback delays every other timer in the system, not only this encoder.

It must not call rotary_encoder_delete(), rotary_encoder_enable_acceleration() or rotary_encoder_disable_acceleration() for the same encoder. Deleting frees the handle the callback is running on, and the other two try to take the lock that the callback already holds. Pass the event to another task instead, through a queue or a task notification.

Enums

enum rotary_encoder_btn_state_t

Button state.

Values:

enumerator RE_BTN_RELEASED

Button currently released.

enumerator RE_BTN_PRESSED

Button currently pressed.

enumerator RE_BTN_LONG_PRESSED

Button currently long pressed.

enum rotary_encoder_event_type_t

Event type.

Values:

enumerator RE_ET_CHANGED

Encoder turned.

enumerator RE_ET_BTN_RELEASED

Button released.

enumerator RE_ET_BTN_PRESSED

Button pressed.

enumerator RE_ET_BTN_LONG_PRESSED

Button long pressed (press time (us) > RE_BTN_LONG_PRESS_TIME_US)

enumerator RE_ET_BTN_CLICKED

Button was clicked.

Functions

esp_err_t rotary_encoder_create(const rotary_encoder_config_t *config, rotary_encoder_handle_t *handle)

Create a new rotary encoder.

Parameters:
  • config – Encoder configuration

  • handle – [out] Encoder handle, populated on success and set to NULL on failure

Returns:

ESP_OK on success ESP_ERR_INVALID_ARG if the configuration is invalid: no callback, an invalid or repeated pin, a button level other than 0 or 1, or an acceleration cap that is zero or larger than the threshold ESP_ERR_NO_MEM if there is no memory for the encoder or its lock the error code of the GPIO or timer that could not be set up

esp_err_t rotary_encoder_delete(rotary_encoder_handle_t handle)

Delete a rotary encoder.

The timer is stopped and the handle is freed, and the GPIO pins are released back to the floating input state.

With ESP-IDF 6.1 and newer the function waits for an event callback that is already running, so the handle is never freed under the callback. Older versions have no such way to wait: the timer stops dispatching callbacks and the lock orders the callbacks that have started, but a callback that the timer task has dispatched and that has not taken the lock yet can still run after the handle is freed. On those versions delete the encoder only when the events are no longer expected, for example after the buttons have been idle for a polling interval.

Parameters:

handle – Encoder handle

Returns:

ESP_OK on success ESP_ERR_INVALID_ARG if the handle is NULL ESP_ERR_INVALID_STATE if called from the event callback of the same encoder

esp_err_t rotary_encoder_enable_acceleration(rotary_encoder_handle_t handle, uint16_t coeff)

Enable acceleration on the rotary encoder.

Parameters:
  • handle – Encoder handle

  • coeff – Acceleration coefficient. Higher value means faster acceleration, 0 and 1 disable it

Returns:

ESP_OK on success ESP_ERR_INVALID_ARG if the handle is NULL ESP_ERR_INVALID_STATE if called from the event callback of the same encoder

esp_err_t rotary_encoder_disable_acceleration(rotary_encoder_handle_t handle)

Disable acceleration on the rotary encoder.

Parameters:

handle – Encoder handle

Returns:

ESP_OK on success ESP_ERR_INVALID_ARG if the handle is NULL ESP_ERR_INVALID_STATE if called from the event callback of the same encoder

struct rotary_encoder_event_t
#include <encoder.h>

Event.

Public Members

rotary_encoder_event_type_t type

Event type.

rotary_encoder_handle_t sender

Encoder handle.

int32_t diff

Difference between new and old positions (only if type == RE_ET_CHANGED)

struct rotary_encoder_config_t
#include <encoder.h>

Per-encoder configuration.

The polling interval is not a part of the configuration: it is fixed at build time by CONFIG_RE_INTERVAL_US, because the driver counts the time of a button press in polling intervals and a per-encoder value would make that accounting depend on a field the caller can change at will.

Public Members

gpio_num_t pin_a

Encoder pin A.

gpio_num_t pin_b

Encoder pin B.

gpio_num_t pin_btn

Button pin, or GPIO_NUM_NC if unused.

uint8_t btn_pressed_level

GPIO level when button is pressed (0 or 1).

When set to 1, the button pin may need a pull-down resistor, either external or set via gpio_set_pull_mode() before creating the encoder, as enable_internal_pullup only enables pull-ups.

bool enable_internal_pullup

Enable internal pull-up resistors on all encoder and.

button GPIO pins. Pull-ups are always used, which is correct for encoders wired with common to ground. When btn_pressed_level is 1, the button pin may need a pull-down instead — use an external resistor or call gpio_set_pull_mode() before creating the encoder. When false, pull resistors are left unchanged.

uint32_t btn_dead_time_us

Button dead time in microseconds.

uint32_t btn_long_press_time_us

Long press threshold in microseconds.

uint32_t acceleration_threshold_ms

Acceleration threshold in milliseconds (acceleration starts below this interval)

uint32_t acceleration_cap_ms

Acceleration cap in milliseconds (minimum interval, limits max acceleration)

rotary_encoder_event_cb_t callback

Event callback (required)

void *callback_ctx

User context passed to callback.