Lectura de un encoder rotativo con ESP32 usando polling e interrupciones

Los encoders rotativos son dispositivos de entrada versátiles y altamente útiles en proyectos con microcontroladores como el ESP32. Con solo girar el knob de un encoder, se pueden realizar diversas acciones, desde navegar por menús hasta ajustar valores o controlar la velocidad de dispositivos. Esta interacción es mucho más intuitiva en comparación con el uso de botones tradicionales que requieren múltiples pulsaciones. En este artículo, abordaremos cómo conectar un encoder rotativo al ESP32, leer su posición y comprender su funcionamiento interno.

Antes de entrar en detalles técnicos, es fundamental entender el principio de funcionamiento de un encoder rotativo y cómo se puede implementar en proyectos prácticos.

Índice de contenido
  1. Cómo funcionan los encoders rotativos
  2. Pinout del encoder rotativo
  3. Conexión de un encoder rotativo al ESP32
  4. Ejemplo 1 – Lectura de un encoder rotativo sin interrupciones (método de sondeo)
    1. Explicación del código: sondeo
  5. Ejemplo 2 – Lectura de un encoder rotativo con interrupciones
    1. Explicación del código: interrupciones

Cómo funcionan los encoders rotativos

Un encoder rotativo consiste en un disco circular que contiene patrones de contacto. Este disco está acoplado al knob que se gira, y está conectado a un pin común conocido como “C” que actúa como tierra. Además, el encoder cuenta con dos pines importantes, denominados “A” y “B”, que son cruciales para determinar la dirección de rotación.

Al girar el knob del encoder, el disco circular rota junto con él. Durante este proceso, los pines A y B hacen contacto con el pin común (C) en secuencias específicas. Lo interesante es que, debido a la disposición de los contactos, A y B no hacen contacto al mismo tiempo. Uno de los pines siempre toca primero que el otro, generando dos señales que están ligeramente desincronizadas. Este fenómeno se conoce técnicamente como señales cuadratura, ya que están desfasadas 90°.

Gracias a esta diferencia de fase, se puede detectar la dirección de rotación observando cuál de las señales se adelanta a la otra. Si A lleva a B, el eje gira en una dirección; si B lleva a A, el eje gira en la dirección opuesta. Esta lógica se puede implementar en el software de forma sencilla, monitorizando un canal y verificando el estado del otro cada vez que hay un cambio.

Esta técnica básica es útil para proyectos iniciales, pero métodos más avanzados pueden rastrear cambios en ambos pines para una decodificación más precisa.

Pinout del encoder rotativo

Conocer la disposición de los pines de un encoder rotativo es esencial para su correcta conexión. Aquí te presentamos el pinout típico:

  • GND: Conexión a tierra.
  • VCC: Proporciona energía al encoder; se conecta a la salida de 3.3V del ESP32.
  • SW (Switch): Conectado a un botón integrado en el encoder. Este pin normalmente está en “HIGH” gracias a una resistencia pull-up interna del ESP32. Al presionar el botón, el pin se lleva a “LOW”.
  • CLK (Canal A): Proporciona una de las dos señales de salida cuadratura utilizadas para detectar la rotación. Este pin se conecta a un GPIO del ESP32.
  • DT (Canal B): Proporciona la segunda señal de salida cuadratura, desfasada 90 grados respecto al canal A. Se conecta a otro GPIO en el ESP32.

Conexión de un encoder rotativo al ESP32

Una vez que comprendemos los fundamentos de un encoder rotativo, es hora de conectarlo al ESP32 y ponerlo en funcionamiento. La conexión es bastante sencilla:

Primero, se debe proporcionar energía al encoder. Conecta el pin +V del encoder al pin de 3.3V del ESP32 y el pin GND al GND del ESP32.

A continuación, conectamos los pines de señal. El pin CLK del encoder se conectará al GPIO #25 del ESP32, mientras que el pin DT irá al GPIO #26. Finalmente, conecta el pin SW al GPIO #27.

La siguiente tabla detalla las conexiones:

Pin del encoderConexión en el ESP32
+V3.3V
GNDGND
CLKGPIO #25
DTGPIO #26
SWGPIO #27

Después de realizar las conexiones, tu encoder rotativo estará listo para usarse con el ESP32.

Ejemplo 1 – Lectura de un encoder rotativo sin interrupciones (método de sondeo)

A continuación, se presenta un programa simple para leer un encoder rotativo utilizando el método de sondeo. En este enfoque, el ESP32 verifica continuamente el estado del pin CLK dentro del bucle principal. Cuando detecta un cambio, reacciona solo al flanco ascendente, luego lee el pin DT en ese momento para determinar la dirección de rotación.

Este método es fácil de entender y funciona bien para proyectos básicos, por lo que es una buena forma de comenzar a aprender cómo se leen los encoders rotativos en software.

Primero, sube y prueba el siguiente código, luego lo analizaremos paso a paso para entender su funcionamiento.

#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27

int counter = 0;
int currentStateCLK;
int lastStateCLK;
String currentDir = "";
unsigned long lastButtonPress = 0;

void setup() {
  pinMode(CLK_PIN, INPUT_PULLUP);
  pinMode(DT_PIN, INPUT_PULLUP);
  pinMode(SW_PIN, INPUT_PULLUP);

  Serial.begin(115200);
  lastStateCLK = digitalRead(CLK_PIN);
}

void loop() {
  currentStateCLK = digitalRead(CLK_PIN);

  if (currentStateCLK != lastStateCLK && currentStateCLK == HIGH) {
    if (digitalRead(DT_PIN) != currentStateCLK) {
      counter--;
      currentDir = "CCW";
    } else {
      counter++;
      currentDir = "CW";
    }

    Serial.print("Direction: ");
    Serial.print(currentDir);
    Serial.print(" | Counter: ");
    Serial.println(counter);
  }

  lastStateCLK = currentStateCLK;

  int btnState = digitalRead(SW_PIN);

  if (btnState == LOW) {
    if (millis() - lastButtonPress > 200) {
      Serial.println("Button pressed!");
      lastButtonPress = millis();
    }
  }

  delay(1);
}

Una vez que subas el código a tu ESP32, abre el Monitor Serial. Gira el knob en ambas direcciones y observa cómo cambian los valores del contador en la pantalla. Intenta presionar el botón y verifica si aparece el mensaje “¡Botón presionado!”

Si notas que la dirección de rotación es opuesta a la que esperabas, puedes solucionar esto simplemente intercambiando las conexiones de los pines CLK y DT.

Explicación del código: sondeo

Al inicio del código, definimos cuáles son los pines GPIO del ESP32 que se conectan a los pines CLK, DT y SW del encoder.

#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27

También creamos varias variables importantes:

  1. La variable counter realiza un seguimiento de cuántos pasos ha rotado el encoder.
  2. Las variables currentStateCLK y lastStateCLK almacenan el estado del pin CLK en diferentes momentos, lo que ayuda a detectar el movimiento.
  3. La variable currentDir indica si el encoder está girando en sentido horario (CW) o antihorario (CCW).
  4. Por último, la variable lastButtonPress ayuda a prevenir pulsaciones de botón falsas, asegurando que no contemos múltiples pulsaciones cuando solo se ha presionado una vez.
int counter = 0;
int currentStateCLK;
int lastStateCLK;
String currentDir = "";
unsigned long lastButtonPress = 0;

En la función setup(), primero configuramos los pines del encoder como entradas para poder leer sus señales. En el ESP32, es recomendable habilitar las resistencias pull-up internas en los pines del encoder así como en el pin del interruptor. Esto mantiene las señales de entrada estables y ayuda a prevenir lecturas erráticas cuando los contactos están abiertos.

Además, iniciamos la comunicación Serial para poder visualizar lo que está ocurriendo.

Finalmente, leemos el estado inicial del pin CLK y lo almacenamos en lastStateCLK. Esto es importante porque nos da un punto de referencia para detectar cuando el knob se mueve.

pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);

Serial.begin(115200);

lastStateCLK = digitalRead(CLK_PIN);

En la sección loop(), leemos el estado actual del pin CLK y lo comparamos con el estado anterior almacenado en lastStateCLK. Si el estado ha cambiado, sabemos que el knob se ha movido.

Para evitar contar el mismo paso dos veces, reaccionamos solo cuando la señal de CLK se vuelve HIGH. Esto significa que estamos utilizando solo un flanco de la señal para contar.

Para determinar en qué dirección se está moviendo el encoder, verificamos el pin DT en ese momento:

  • Si DT es diferente de CLK, el encoder está girando en sentido antihorario (CCW), por lo que disminuimos el contador y establecemos currentDir en “CCW”.
  • Si DT es igual a CLK, el encoder está girando en sentido horario (CW), por lo que aumentamos el contador y establecemos currentDir en “CW”.
if (digitalRead(DT_PIN) != currentStateCLK) {
  counter--;
  currentDir = "CCW";
} else {
  counter++;
  currentDir = "CW";
}

Luego, mostramos la dirección y el valor del contador en el Monitor Serial.

Serial.print("Direction: ");
Serial.print(currentDir);
Serial.print(" | Counter: ");
Serial.println(counter);

En este punto, actualizamos lastStateCLK para que coincida con el estado actual de CLK, de modo que la próxima vez que se ejecute el bucle, podamos detectar correctamente un nuevo cambio.

lastStateCLK = currentStateCLK;

Para el botón, verificamos si está presionado, lo que sucede cuando el pin SW lee LOW. Esto ocurre porque el pin está utilizando una resistencia pull-up interna, por lo que normalmente se encuentra en HIGH y solo se vuelve LOW cuando se presiona el botón.

Para evitar lecturas erróneas causadas por el rebote del botón, verificamos si ha pasado al menos 50 milisegundos desde la última pulsación válida. Si ha pasado suficiente tiempo, imprimimos “¡Botón presionado!” en el Monitor Serial y guardamos el tiempo actual para que se puedan detectar correctamente pulsaciones futuras.

int btnState = digitalRead(SW_PIN);

if (btnState == LOW) {
  if (millis() - lastButtonPress > 200) {
    Serial.println("Button pressed!");
    lastButtonPress = millis();
  }
}

Terminamos el bucle con un pequeño retraso de 1 milisegundo para ayudar a estabilizar las lecturas antes de comenzar el siguiente ciclo.

Ejemplo 2 – Lectura de un encoder rotativo con interrupciones

En el ejemplo anterior, leímos el encoder rotativo utilizando un método de sondeo. El ESP32 verificaba repetidamente el estado de los pines CLK y DT dentro de la función loop() para ver si el knob había cambiado de posición. Este método es simple y funciona bien para proyectos básicos, pero tiene algunas limitaciones.

  • Primero, el ESP32 tiene que seguir leyendo los pines del encoder todo el tiempo, incluso cuando el knob no se está moviendo.
  • En segundo lugar, el sondeo no siempre responde de inmediato. Si el ESP32 está ocupado realizando otras tareas, puede haber un pequeño retraso antes de notar que el encoder se ha movido.
  • Por último, si el knob se gira muy rápido, algunos cambios pueden ocurrir entre dos lecturas de los pines. En ese caso, el ESP32 puede perder algunos pasos.

Para solucionar esto, podemos utilizar interrupciones. Una interrupción permite que el ESP32 reaccione de inmediato cada vez que cambia la señal del encoder. En lugar de estar constantemente revisando los pines, el procesador solo responde cuando es necesario. Esto hace que el sistema sea más eficiente y mejora su fiabilidad.

Hay una diferencia más entre este ejemplo y el anterior. En el ejemplo de sondeo, utilizamos un método de decodificación de 1 flanco, donde observamos una señal y verificamos la otra para determinar la dirección. En este ejemplo, utilizamos un método de transición de estado completo. Vamos a monitorear los cambios en A y B, almacenar los estados anteriores y actuales, y decodificar el movimiento a partir de la secuencia de esos estados.

Este método es más completo para leer un encoder rotativo porque este no produce simplemente un evento de “A cambió, así que verifica B”. En su lugar, genera una secuencia de estados. Los cuatro estados posibles son 00, 01, 11 y 10, y el orden de estos estados nos indica la dirección de rotación. Si los estados aparecen en un orden, el encoder está girando en sentido horario (CW). Si aparecen en el orden inverso, está girando en sentido antihorario (CCW).

Ahora, probemos el siguiente código basado en interrupciones, y luego lo desglosaremos paso a paso.

#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27

volatile int32_t encoderCount = 0;  // posición completa decodificada
volatile uint8_t encoderState = 0;  // último estado de 2 bits AB

volatile bool buttonPressed = false;
volatile uint32_t lastButtonInterruptMs = 0;

static const int8_t QUAD_TABLE[16] = {
  0, -1, +1, 0,
  +1, 0, 0, -1,
  -1, 0, 0, +1,
  0, +1, -1, 0
};

void IRAM_ATTR handleEncoderISR() {
  uint8_t a = digitalRead(CLK_PIN);
  uint8_t b = digitalRead(DT_PIN);

  uint8_t currentState = (a << 1) | b;
  uint8_t index = (encoderState < 200) {
    buttonPressed = true;
    lastButtonInterruptMs = now;
  }
}

int32_t readEncoderCount() {
  noInterrupts();
  int32_t value = encoderCount;
  interrupts();
  return value;
}

int32_t readEncoderDetents() {
  return readEncoderCount() / 4;
}

void setup() {
  Serial.begin(115200);

  pinMode(CLK_PIN, INPUT_PULLUP);
  pinMode(DT_PIN, INPUT_PULLUP);
  pinMode(SW_PIN, INPUT_PULLUP);

  uint8_t a = digitalRead(CLK_PIN);
  uint8_t b = digitalRead(DT_PIN);
  encoderState = (a < 0) {
      Serial.printf("Direction: CW  | Counter: %ldn", (long)detents);
    } else {
      Serial.printf("Direction: CCW | Counter: %ldn", (long)detents);
    }
  }

  if (buttonPressed) {
    noInterrupts();
    buttonPressed = false;
    interrupts();

    Serial.println("Button pressed");
  }

  delay(1);
}

Después de subir el código a tu ESP32, abre el Monitor Serial. Gira el knob en ambas direcciones y observa cómo cambian los valores del contador en la pantalla. Intenta presionar el botón y verifica si aparece el mensaje “¡Botón presionado!”

Explicación del código: interrupciones

Al principio del código, definimos cuáles son los pines GPIO del ESP32 que se conectan a los pines CLK, DT y SW del encoder.

#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27

A continuación, creamos las variables necesarias para rastrear los estados del encoder y del botón.

  1. La variable encoderCount almacena la posición del encoder. Dado que este valor se modifica dentro de una rutina de servicio de interrupción, se declara como volatile. Esto le dice al compilador que el valor puede cambiar en cualquier momento, por lo que siempre debe leerse directamente desde la memoria.
  2. La variable encoderState guarda el último estado de 2 bits de las señales del encoder. También se declara como volatile porque se actualiza dentro de la interrupción.
  3. La variable buttonPressed se utiliza para indicar al programa principal que el botón fue presionado.
  4. La variable lastButtonInterruptMs ayuda con el rebote del botón al almacenar el tiempo de la última pulsación válida.
volatile int32_t encoderCount = 0;
volatile uint8_t encoderState = 0;

volatile bool buttonPressed = false;
volatile uint32_t lastButtonInterruptMs = 0;

A continuación, definimos una tabla de decodificación cuadratura. Un encoder rotativo produce dos señales, que juntas forman cuatro estados posibles: 00, 01, 11 y 10. A medida que el knob gira, el encoder transita por estos estados en secuencia. Una secuencia representa una rotación en sentido horario, mientras que la secuencia inversa representa una rotación en sentido antihorario.

Esta tabla nos ayuda a determinar rápidamente si un cambio de estado significa +1, -1 o ningún movimiento válido en absoluto.

static const int8_t QUAD_TABLE[16] = {
  0, -1, +1, 0,
  +1, 0, 0, -1,
  -1, 0, 0, +1,
  0, +1, -1, 0
};

El índice de la tabla se calcula a partir del estado anterior del encoder y el estado actual del encoder. Las transiciones válidas producen +1 o -1, mientras que las transiciones no válidas, que pueden ocurrir debido al rebote del interruptor o al ruido, producen 0.

A continuación, tenemos la rutina de servicio de interrupción para el encoder. Esta función se ejecuta automáticamente cada vez que cambian los pines CLK o DT. El atributo IRAM_ATTR le dice al ESP32 que coloque esta función en la RAM para que se pueda ejecutar rápidamente durante una interrupción.

void IRAM_ATTR handleEncoderISR() {
  uint8_t a = digitalRead(CLK_PIN);
  uint8_t b = digitalRead(DT_PIN);

  uint8_t currentState = (a << 1) | b;
  uint8_t index = (encoderState << 2) | currentState;

  encoderCount += QUAD_TABLE[index];
  encoderState = currentState;
}

Dentro de la función, leemos ambos pines del encoder lo más rápido posible. Luego combinamos las dos lecturas de pin en un solo número de 2 bits llamado currentState. Por ejemplo:

  • Si CLK = 0 y DT = 0, el estado es 00
  • Si CLK = 0 y DT = 1, el estado es 01
  • Si CLK = 1 y DT = 0, el estado es 10
  • Si CLK = 1 y DT = 1, el estado es 11

Después de eso, combinamos el estado anterior y el estado actual para crear un índice para la tabla de búsqueda.

uint8_t index = (encoderState << 2) | currentState;

Usando ese índice, leemos el valor de movimiento de QUAD_TABLE y lo sumamos a encoderCount.

encoderCount += QUAD_TABLE[index];

Finalmente, guardamos el estado actual en encoderState para que se convierta en el estado anterior para la próxima interrupción.

encoderState = currentState;

Luego está la rutina de servicio de interrupción para el botón. Esta función se ejecuta cada vez que el pin del botón detecta un flanco de bajada, lo que ocurre cuando se presiona el botón.

void IRAM_ATTR handleButtonISR() {
  uint32_t now = millis();
  if (now - lastButtonInterruptMs > 200) {
    buttonPressed = true;
    lastButtonInterruptMs = now;
  }
}

El código usa un método de rebote simple. Verifica cuánto tiempo ha pasado desde la última pulsación válida. Si han pasado más de 200 milisegundos, trata la pulsación del botón como válida.

En lugar de imprimir directamente dentro de la interrupción, el código simplemente establece buttonPressed en true. El bucle principal manejará el mensaje real más tarde. Este es un diseño más seguro porque las funciones de interrupción deben mantenerse lo más cortas posible.

Después de eso, tenemos una función auxiliar llamada readEncoderCount(). Esta función lee el contador del encoder de manera segura.

int32_t readEncoderCount() {
  noInterrupts();
  int32_t value = encoderCount;
  interrupts();
  return value;
}

Dado que encoderCount se actualiza dentro de una interrupción, el valor podría cambiar mientras el programa principal intenta leerlo. Para evitar eso, deshabilitamos temporalmente las interrupciones utilizando noInterrupts(), copiamos el valor en una variable local y luego habilitamos nuevamente las interrupciones con interrupts(). Esto hace que la lectura sea atómica, lo que significa que el valor se lee de manera segura en un solo paso sin interrupciones.

A continuación, hay otra función auxiliar:

int32_t readEncoderDetents() {
  return readEncoderCount() / 4;
}

Muchos encoders rotativos generan cuatro cambios de estado por clic físico, también llamado detente. Dado que encoderCount aumenta o disminuye en cada transición válida, dividir por 4 convierte el conteo bruto en el número de clics reales del knob.

Luego, pasamos a la función setup(). Primero, iniciamos la comunicación serial para poder imprimir mensajes en el Monitor Serial. Luego configuramos los pines del encoder y del botón como INPUT_PULLUP. Esto habilita las resistencias pull-up internas del ESP32, lo que ayuda a mantener las señales estables.

Serial.begin(115200);

pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);

Antes de habilitar las interrupciones, leemos los estados actuales de CLK y DT y los almacenamos en encoderState. Esto le da al programa el estado inicial correcto, por lo que la primera interrupción se puede decodificar correctamente.

uint8_t a = digitalRead(CLK_PIN);
uint8_t b = digitalRead(DT_PIN);
encoderState = (a << 1) | b;

A continuación, adjuntamos las interrupciones a ambos pines del encoder utilizando el modo CHANGE. Esto significa que la interrupción se activará cada vez que cualquiera de las señales cambie de LOW a HIGH o de HIGH a LOW. Esto es importante porque el método de transición de estado completo necesita observar cada cambio en ambos pines.

attachInterrupt(digitalPinToInterrupt(CLK_PIN), handleEncoderISR, CHANGE);
attachInterrupt(digitalPinToInterrupt(DT_PIN), handleEncoderISR, CHANGE);

También adjuntamos una interrupción de botón al pin SW utilizando FALLING, porque el botón se considera presionado cuando la señal cambia de HIGH a LOW.

attachInterrupt(digitalPinToInterrupt(SW_PIN), handleButtonISR, FALLING);

Ahora veamos la función loop(). Dentro del bucle, primero creamos una variable estática llamada lastReportedDetent. Esta recuerda la última posición del encoder que se imprimió en el Monitor Serial.

static int32_t lastReportedDetent = 0;

Luego leemos la posición actual del encoder en detentes llamando a readEncoderDetents().

int32_t detents = readEncoderDetents();

Si el valor ha cambiado, sabemos que el encoder se ha movido. Calculamos la diferencia entre la nueva posición y la antigua.

  • Si la diferencia es positiva, el encoder se movió en sentido horario, por lo que imprimimos CW junto con el valor del contador actualizado.
  • Si la diferencia es negativa, el encoder se movió en sentido antihorario, por lo que imprimimos CCW junto con el valor del contador actualizado.
if (detents != lastReportedDetent) {
  int32_t diff = detents - lastReportedDetent;
  lastReportedDetent = detents;

  if (diff > 0) {
    Serial.printf("Direction: CW  | Counter: %ldn", (long)detents);
  } else {
    Serial.printf("Direction: CCW | Counter: %ldn", (long)detents);
  }
}

Después de eso, verificamos si la interrupción del botón ha establecido buttonPressed en true. Si es así, deshabilitamos de forma segura la interrupción mientras temporalmente desactivamos la interrupción, y luego imprimimos “¡Botón presionado!” en el Monitor Serial.

if (buttonPressed) {
  noInterrupts();
  buttonPressed = false;
  interrupts();

  Serial.println("Button pressed");
}

Este diseño mantiene la rutina de interrupción muy corta y permite que el bucle principal maneje la tarea más lenta de imprimir.

Finalmente, el bucle termina con un pequeño retraso de 1 ms. Esto no es necesario para el manejo de interrupciones, pero ayuda a evitar que el bucle se ejecute innecesariamente rápido y es perfectamente aceptable en este ejemplo.

Carlos Julián

Carlos Julián es el fundador de Ingtelecto, es Ingeniero Mecatrónico, Profesor y Programador, cuenta con una Maestria en Ciencias de la Educación, creador de contenido educativo en redes sociales. Lee más sobre mi trayectoria aquí.

Estos temas te pueden interesar

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Tu puntuación: Útil

Subir