Encoder rotativo EC11 con ESP32-C6: PCNT, accelerazione e menu

Encoder rotativo EC11 con ESP32-C6: PCNT, accelerazione e menu

Una manopola con encoder rotativo è l’interfaccia più comoda per un dispositivo con un piccolo display: giri per scorrere, premi per confermare, e non hai bisogno di cinque pulsanti diversi. In questo articolo colleghi un encoder meccanico tipo EC11 con pulsante integrato a un ESP32-C6 e lo leggi nel modo giusto, cioè con la periferica hardware PCNT (Pulse Counter) di ESP-IDF 5.4, senza interrupt su ogni fronte e senza perdere passi. Aggiungiamo il debounce del pulsante, l’accelerazione (giri veloce, il valore corre) e un esempio completo di navigazione in un menu.

Cosa serve

Componente Esempio Note
Scheda di sviluppo ESP32-C6-DevKitC-1 Qualsiasi modulo ESP32-C6 va bene, cambiano solo i pin disponibili
Encoder rotativo EC11 con pulsante (5 pin: A, B, C comune, due pin del pulsante) Esiste in molte varianti: numero di scatti e impulsi per giro vanno letti sul datasheet del tuo modello
Resistenze di pull-up 3 × 10 kΩ Consigliate su A, B e pulsante; i pull-up interni (che il driver PCNT attiva da solo) funzionano ma sono deboli, dell’ordine delle decine di kΩ
Condensatori (opzionali) 3 × 10 nF Filtro RC su A e B se i fili sono lunghi o l’ambiente è rumoroso
Modulo pronto (alternativa) Breakout “KY-040” o simili Include già i pull-up su A e B; verifica sullo schema del modulo a quale tensione sono collegati

Come funziona

Dentro l’EC11 ci sono due contatti striscianti, A e B, che si chiudono verso il comune (C) mentre ruoti. I due segnali sono onde quadre sfasate di 90°: è la famosa quadratura. Guardando quale dei due cambia per primo capisci il verso di rotazione; contando tutti i fronti di entrambi i segnali ottieni la risoluzione massima, detta x4.

Su un EC11 tipico ogni scatto meccanico corrisponde a un ciclo completo di quadratura, cioè 4 fronti. Esistono però varianti con un numero diverso di impulsi per scatto: per questo nel codice c’è la costante COUNTS_PER_DETENT, che tariamo sperimentalmente (giri di uno scatto e guardi di quanto cambia il contatore).

I contatti meccanici rimbalzano: a ogni commutazione il segnale oscilla per qualche centinaio di microsecondi. La decodifica in quadratura è per sua natura robusta a questo problema: un rimbalzo su A mentre B è fermo produce un +1 seguito da un −1, che si annullano. È uno dei motivi per cui conviene sempre decodificare x4 e poi dividere per 4, invece di contare solo i fronti di salita di A. In più il PCNT ha un filtro glitch hardware che scarta gli impulsi più brevi di una soglia.

Il pulsante invece va “debounciato” in software: lo campioniamo ogni 10 ms con un integratore che deve restare stabile per 3 campioni consecutivi prima di cambiare stato. Distinguiamo pressione breve (al rilascio) e pressione lunga (dopo 800 ms di pressione continua).

L’accelerazione si basa sul tempo tra due scatti: se è sotto i 30 ms il passo viene moltiplicato per 10, sotto gli 80 ms per 3, altrimenti vale 1. Così regoli un valore di precisione girando piano, ma attraversi un intervallo da 100 a 8000 con un paio di giri veloci.

Collegamenti

Pin encoder GPIO ESP32-C6 Note
A GPIO 4 Pull-up 10 kΩ verso 3,3 V
B GPIO 5 Pull-up 10 kΩ verso 3,3 V
C (comune) GND Il pin centrale del lato a tre pin
Pulsante (SW) GPIO 6 Pull-up interno attivo; pull-up esterno consigliato
Pulsante (secondo pin) GND Il pulsante chiude verso massa: premuto = livello basso

Tutto lavora a 3,3 V. Se usi un modulo breakout con pull-up già montati, alimentalo a 3,3 V e non a 5 V: i GPIO dell’ESP32-C6 non sono tolleranti ai 5 V. Evita i pin di strapping (8, 9, 15), quelli dell’USB (12, 13) e della console (16, 17).

La periferica dell’ESP32-C6

L’ESP32-C6 ha un gruppo PCNT con 4 unità, ciascuna con 2 canali. Ogni canale ha un ingresso “edge” (i cui fronti vengono contati) e un ingresso “level” (che decide se contare in avanti, all’indietro o invertire). Configurando due canali incrociati (A come edge e B come level, poi il contrario) ottieni la decodifica x4 interamente in hardware: la CPU non viene mai interrotta mentre ruoti la manopola, e non perdi un passo neanche se il firmware è occupato a disegnare sul display.

Il contatore hardware è a 16 bit con segno. Il driver driver/pulse_cnt.h offre l’opzione accum_count: quando il contatore raggiunge un limite, il driver lo azzera e accumula il valore in software, così pcnt_unit_get_count() restituisce un conteggio che non va mai in overflow. Perché funzioni, i limiti devono essere registrati come watch point. I watch point servono anche per altro: puoi chiedere al PCNT di chiamare una callback quando il conteggio raggiunge un valore preciso (utile per esempio con un encoder di posizione e un finecorsa virtuale). Nel nostro caso la callback conta solo gli eventi di limite, a scopo diagnostico.

Per un menu non servono eventi a valori fissi ma variazioni relative, quindi leggiamo il contatore ogni 10 ms (polling): è semplice e l’hardware conta comunque tutto nel frattempo.

Il codice

Il progetto completo, verificato con idf.py build su ESP-IDF 5.4 per target esp32c6, è composto dai soliti CMakeLists.txt e da questo main/main.c:

/*
 * Manopola encoder EC11 su ESP32-C6 (ESP-IDF 5.4)
 * - decodifica in quadratura x4 con la periferica PCNT (due canali)
 * - pulsante integrato con debounce software (pressione breve / lunga)
 * - accelerazione: piu' giri veloce, piu' grande e' il passo
 * - esempio pratico: navigazione di un menu con modifica dei valori
 * www.webappportal.com
 */
#include <stdio.h>
#include <stdbool.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/pulse_cnt.h"
#include "driver/gpio.h"
#include "esp_timer.h"
#include "esp_log.h"

static const char *TAG = "encoder";

#define ENC_GPIO_A          4
#define ENC_GPIO_B          5
#define ENC_GPIO_SW         6

#define PCNT_LIMIT          1000    /* limiti del contatore hardware (16 bit con segno) */
#define COUNTS_PER_DETENT   4       /* EC11 tipico: 4 fronti per scatto, verifica sul tuo modello */
#define POLL_MS             10
#define DEBOUNCE_SAMPLES    3       /* 3 campioni x 10 ms = 30 ms di stabilita' */
#define LONG_PRESS_US       800000

/* Accelerazione: tempo tra due scatti consecutivi -> moltiplicatore */
#define ACCEL_FAST_US       30000
#define ACCEL_MEDIUM_US     80000

static volatile int s_limit_events;

/* Callback dei watch point: gira in ISR, deve essere breve */
static bool pcnt_on_reach(pcnt_unit_handle_t unit, const pcnt_watch_event_data_t *edata, void *user_ctx)
{
    s_limit_events++;
    return false;
}

static pcnt_unit_handle_t encoder_init(void)
{
    pcnt_unit_config_t unit_cfg = {
        .low_limit = -PCNT_LIMIT,
        .high_limit = PCNT_LIMIT,
        .flags.accum_count = true,  /* il driver accumula oltre i limiti */
    };
    pcnt_unit_handle_t unit = NULL;
    ESP_ERROR_CHECK(pcnt_new_unit(&unit_cfg, &unit));

    pcnt_glitch_filter_config_t filter_cfg = {
        .max_glitch_ns = 1000,      /* impulsi piu' corti di 1 us vengono ignorati */
    };
    ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(unit, &filter_cfg));

    /* Canale A: conta i fronti di A, guarda il livello di B */
    pcnt_chan_config_t cha_cfg = {
        .edge_gpio_num = ENC_GPIO_A,
        .level_gpio_num = ENC_GPIO_B,
    };
    pcnt_channel_handle_t cha = NULL;
    ESP_ERROR_CHECK(pcnt_new_channel(unit, &cha_cfg, &cha));

    /* Canale B: conta i fronti di B, guarda il livello di A */
    pcnt_chan_config_t chb_cfg = {
        .edge_gpio_num = ENC_GPIO_B,
        .level_gpio_num = ENC_GPIO_A,
    };
    pcnt_channel_handle_t chb = NULL;
    ESP_ERROR_CHECK(pcnt_new_channel(unit, &chb_cfg, &chb));

    ESP_ERROR_CHECK(pcnt_channel_set_edge_action(cha, PCNT_CHANNEL_EDGE_ACTION_DECREASE,
                                                 PCNT_CHANNEL_EDGE_ACTION_INCREASE));
    ESP_ERROR_CHECK(pcnt_channel_set_level_action(cha, PCNT_CHANNEL_LEVEL_ACTION_KEEP,
                                                  PCNT_CHANNEL_LEVEL_ACTION_INVERSE));
    ESP_ERROR_CHECK(pcnt_channel_set_edge_action(chb, PCNT_CHANNEL_EDGE_ACTION_INCREASE,
                                                 PCNT_CHANNEL_EDGE_ACTION_DECREASE));
    ESP_ERROR_CHECK(pcnt_channel_set_level_action(chb, PCNT_CHANNEL_LEVEL_ACTION_KEEP,
                                                  PCNT_CHANNEL_LEVEL_ACTION_INVERSE));

    /* I limiti devono essere watch point perche' l'accumulo funzioni */
    ESP_ERROR_CHECK(pcnt_unit_add_watch_point(unit, PCNT_LIMIT));
    ESP_ERROR_CHECK(pcnt_unit_add_watch_point(unit, -PCNT_LIMIT));
    pcnt_event_callbacks_t cbs = {
        .on_reach = pcnt_on_reach,
    };
    ESP_ERROR_CHECK(pcnt_unit_register_event_callbacks(unit, &cbs, NULL));

    ESP_ERROR_CHECK(pcnt_unit_enable(unit));
    ESP_ERROR_CHECK(pcnt_unit_clear_count(unit));
    ESP_ERROR_CHECK(pcnt_unit_start(unit));
    return unit;
}

/* Restituisce gli scatti interi dall'ultima lettura e il moltiplicatore di accelerazione */
static int encoder_read(pcnt_unit_handle_t unit, int *accel)
{
    static int last_count = 0;
    static int64_t last_detent_us = 0;

    int count = 0;
    ESP_ERROR_CHECK(pcnt_unit_get_count(unit, &count));
    int detents = (count - last_count) / COUNTS_PER_DETENT;
    *accel = 1;
    if (detents == 0) {
        return 0;
    }
    last_count += detents * COUNTS_PER_DETENT;  /* il mezzo scatto residuo resta in conto */

    int64_t now = esp_timer_get_time();
    int64_t dt = now - last_detent_us;
    last_detent_us = now;
    if (dt < ACCEL_FAST_US) {
        *accel = 10;
    } else if (dt < ACCEL_MEDIUM_US) {
        *accel = 3;
    }
    return detents;
}

typedef enum { BTN_NONE, BTN_SHORT, BTN_LONG } btn_event_t;

/* Debounce a integratore: il pulsante chiude verso GND (attivo basso) */
static btn_event_t button_poll(void)
{
    static int integrator = 0;
    static bool pressed = false;
    static bool long_sent = false;
    static int64_t press_t0 = 0;

    bool raw = (gpio_get_level(ENC_GPIO_SW) == 0);
    if (raw && integrator < DEBOUNCE_SAMPLES) {
        integrator++;
    } else if (!raw && integrator > 0) {
        integrator--;
    }

    int64_t now = esp_timer_get_time();
    if (!pressed && integrator == DEBOUNCE_SAMPLES) {
        pressed = true;
        long_sent = false;
        press_t0 = now;
    } else if (pressed && integrator == 0) {
        pressed = false;
        if (!long_sent) {
            return BTN_SHORT;
        }
    }
    if (pressed && !long_sent && (now - press_t0) >= LONG_PRESS_US) {
        long_sent = true;
        return BTN_LONG;
    }
    return BTN_NONE;
}

typedef struct {
    const char *name;
    int value;
    int min;
    int max;
} menu_item_t;

static menu_item_t s_menu[] = {
    { "Luminosita' LED %",   50,   0,   100 },
    { "Setpoint x0.1 C",    205,  50,   300 },
    { "Timeout display s",   30,   5,   600 },
    { "Frequenza buzzer Hz", 1000, 100, 8000 },
};
#define MENU_LEN ((int)(sizeof(s_menu) / sizeof(s_menu[0])))

static int clamp(int v, int lo, int hi)
{
    return v < lo ? lo : (v > hi ? hi : v);
}

void app_main(void)
{
    gpio_config_t sw_cfg = {
        .pin_bit_mask = 1ULL << ENC_GPIO_SW,
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_DISABLE,
    };
    ESP_ERROR_CHECK(gpio_config(&sw_cfg));
    pcnt_unit_handle_t unit = encoder_init();

    int sel = 0;
    bool editing = false;
    int backup = 0;
    ESP_LOGI(TAG, "Menu pronto. Ruota per scegliere, premi per modificare.");
    ESP_LOGI(TAG, "> [%d] %s = %d", sel, s_menu[sel].name, s_menu[sel].value);

    while (1) {
        int accel;
        int steps = encoder_read(unit, &accel);
        if (steps != 0) {
            if (!editing) {
                /* navigazione: una voce per scatto, senza accelerazione, con giro circolare */
                sel = ((sel + steps) % MENU_LEN + MENU_LEN) % MENU_LEN;
                ESP_LOGI(TAG, "> [%d] %s = %d", sel, s_menu[sel].name, s_menu[sel].value);
            } else {
                menu_item_t *it = &s_menu[sel];
                it->value = clamp(it->value + steps * accel, it->min, it->max);
                ESP_LOGI(TAG, "  %s: %d (x%d)", it->name, it->value, accel);
            }
        }

        switch (button_poll()) {
        case BTN_SHORT:
            if (!editing) {
                editing = true;
                backup = s_menu[sel].value;
                ESP_LOGI(TAG, "Modifica '%s' (breve = conferma, lunga = annulla)", s_menu[sel].name);
            } else {
                editing = false;
                ESP_LOGI(TAG, "Confermato: %s = %d", s_menu[sel].name, s_menu[sel].value);
            }
            break;
        case BTN_LONG:
            if (editing) {
                editing = false;
                s_menu[sel].value = backup;
                ESP_LOGW(TAG, "Annullato: %s torna a %d", s_menu[sel].name, backup);
            } else {
                int raw = 0;
                pcnt_unit_get_count(unit, &raw);
                ESP_LOGI(TAG, "Diagnostica: conteggio=%d, eventi limite=%d", raw, s_limit_events);
            }
            break;
        default:
            break;
        }
        vTaskDelay(pdMS_TO_TICKS(POLL_MS));
    }
}

Vediamo i punti chiave.

  • encoder_init(): crea l’unità PCNT con limiti ±1000 e accum_count, imposta il filtro glitch a 1 µs, crea i due canali incrociati e definisce le azioni. Su un fronte di A, se B è alto il conteggio scende e se B è basso (INVERSE) sale; su B vale il contrario. Il risultato è +4 per ogni ciclo in un verso e −4 nell’altro. Se il verso ti sembra invertito, scambia A e B nel cablaggio o nelle due #define.
  • Watch point e callback: pcnt_on_reach() gira in contesto di interrupt, quindi fa solo un incremento. La registrazione delle callback deve avvenire prima di pcnt_unit_enable().
  • encoder_read(): calcola gli scatti interi trascorsi dall’ultima lettura. Nota che last_count avanza solo di multipli di COUNTS_PER_DETENT: se la manopola è a metà di uno scatto, il resto non va perso. Qui viene misurato anche il tempo tra gli scatti con esp_timer_get_time(), che decide il moltiplicatore di accelerazione.
  • button_poll(): debounce a integratore. È più robusto di un semplice “ignora per 50 ms dopo un fronte”, perché richiede stabilità e non solo un ritardo.
  • Il menu: in navigazione ogni scatto sposta la selezione di una voce, senza accelerazione e con giro circolare. Una pressione breve entra in modifica: ora la rotazione cambia il valore con accelerazione e limiti min/max. Una seconda pressione breve conferma, una lunga annulla e ripristina il valore precedente.

Prova e risultati attesi

Compila e carica con idf.py -p COMx flash monitor. Ruotando lentamente, poi premendo, poi girando veloce, vedrai qualcosa del genere:

I (312) encoder: Menu pronto. Ruota per scegliere, premi per modificare.
I (312) encoder: > [0] Luminosita' LED % = 50
I (2140) encoder: > [1] Setpoint x0.1 C = 205
I (2890) encoder: > [2] Timeout display s = 30
I (3610) encoder: > [3] Frequenza buzzer Hz = 1000
I (4800) encoder: Modifica 'Frequenza buzzer Hz' (breve = conferma, lunga = annulla)
I (5320) encoder:   Frequenza buzzer Hz: 1001 (x1)
I (5410) encoder:   Frequenza buzzer Hz: 1004 (x3)
I (5440) encoder:   Frequenza buzzer Hz: 1014 (x10)
I (5460) encoder:   Frequenza buzzer Hz: 1024 (x10)
I (7020) encoder: Confermato: Frequenza buzzer Hz = 1024

Se ogni scatto sposta la selezione di due voci o di nessuna, il tuo encoder ha un rapporto scatti/impulsi diverso: tieni premuto il pulsante fuori dalla modalità modifica per stampare il conteggio grezzo, gira di uno scatto e correggi COUNTS_PER_DETENT.

Problemi comuni

  • Il valore salta avanti e indietro: manca il pull-up su A o B, oppure il comune non è a massa. Con fili lunghi aggiungi un RC (10 kΩ + 10 nF) e verifica con l’oscilloscopio.
  • Il verso è invertito: scambia ENC_GPIO_A e ENC_GPIO_B.
  • L’accelerazione scatta troppo presto (o mai): è questione di gusto e di manopola. Regola ACCEL_FAST_US e ACCEL_MEDIUM_US; se vuoi una risposta più pronta riduci POLL_MS, ricordando che con il tick FreeRTOS di default (100 Hz) il ritardo minimo è 10 ms. Il conteggio in sé non perde nulla: lo fa l’hardware.
  • Il pulsante genera doppie pressioni: aumenta DEBOUNCE_SAMPLES; alcuni EC11 economici hanno rimbalzi lunghi.
  • Errore alla creazione dell’unità PCNT: le unità sono 4; se altri moduli del tuo firmware (per esempio l’encoder di un motore) ne usano già, le hai finite.

Va oltre

Con un piccolo display OLED questo menu diventa l’interfaccia locale di qualsiasi progetto: il termostato di una centralina domotica, la taratura delle soglie della stazione meteo, o l’inserimento del codice di una tastiera per l’antifurto. Il passo successivo naturale è spostare la lettura in un task dedicato che pubblica eventi (ROT_CW, ROT_CCW, CLICK, LONG) su una coda FreeRTOS, così la logica dell’interfaccia resta separata dall’hardware. Altri esempi con gli ESP32 li trovi nella sezione ESP32.

Imprenditore, maker, MBSE enthusiast e co-autore di Web App Portal. Collegati su LinkedIn.