Chapter 166: Encoder Interface with PCNT

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the working principles of incremental rotary encoders, particularly quadrature encoders.
  • Explain how Phase A, Phase B, and optional Index (Z) signals are used to determine position and direction.
  • Describe different quadrature decoding modes (1x, 2x, 4x) and their implications.
  • Configure the ESP32 PCNT module specifically for robust quadrature encoder interfacing.
  • Implement code to read position and direction from a rotary encoder.
  • Handle common issues like signal noise and debouncing for mechanical encoders.
  • Integrate encoder readings with other functionalities, such as resetting counts or calculating speed.
  • Recognize any variant-specific considerations for encoder interfacing using PCNT.

Introduction

In the previous chapter, we explored the versatile Pulse Counter (PCNT) module. One of its most common and powerful applications is interfacing with rotary encoders. Rotary encoders are electromechanical devices that convert the angular position or motion of a shaft or axle into an analog or digital signal. They are fundamental components in user interfaces (knobs for volume control, menu navigation), robotics (motor position and speed feedback), industrial automation (positioning systems), and many other applications where precise rotational measurement is required.

This chapter will delve specifically into using the PCNT module to interface with incremental quadrature encoders. We will cover the theory behind how these encoders work, the different ways their signals can be decoded for higher resolution, and how to configure the ESP32’s PCNT peripheral to efficiently track rotation and direction. By mastering this, you’ll be able to add sophisticated rotational input and feedback mechanisms to your ESP32 projects.

Theory

What is a Rotary Encoder?

A rotary encoder is a sensor that detects the angular position and/or motion of a rotating shaft. There are two main types:

  1. Absolute Encoders: Provide a unique digital code for each distinct angle of the shaft. Even if power is lost, they know their absolute position when power is restored. They are more complex and generally more expensive.
  2. Incremental Encoders: Generate pulses as the shaft rotates. By counting these pulses, relative changes in position can be determined. If power is lost, the position reference is also lost and needs to be re-established (e.g., by moving to a known home position or using an index pulse).

This chapter focuses on incremental encoders, specifically quadrature encoders, as they are widely used with microcontrollers like the ESP32.

Incremental Quadrature Encoders

A quadrature encoder typically has two output channels, Phase A and Phase B, that produce digital pulse trains. These signals are 90 degrees out of phase with each other. Some encoders also provide a third channel, Index (Z), which outputs a single pulse per revolution at a specific home position.

Phase A and Phase B Signals:

The key to a quadrature encoder is the phase relationship between signals A and B:

  • When the shaft rotates in one direction (e.g., clockwise), one signal will lead the other (e.g., A leads B: A rises before B rises).
  • When the shaft rotates in the opposite direction (e.g., counter-clockwise), the phase leadership reverses (e.g., B leads A: B rises before A rises).

By monitoring both signals, we can determine:

  1. Amount of Rotation: By counting the pulses on either A or B.
  2. Direction of Rotation: By observing which signal transitions first.

Index (Z) Signal (Optional):

The Z signal provides a reference point once per revolution. It can be used for:

  • Homing: Establishing a known zero position after power-up.
  • Revolution Counting: Verifying or correcting the accumulated pulse count over multiple revolutions.The PCNT module itself doesn’t have a dedicated input to automatically reset the count on the Z pulse. Handling the Z pulse typically requires using a separate GPIO interrupt. When the Z pulse interrupt occurs, the software can then explicitly clear the PCNT counter (pcnt_unit_clear_count()).

Quadrature Decoding Modes

The two 90-degree out-of-phase signals (A and B) create four distinct states for each cycle of the encoder.

Let’s represent the states as (Phase B, Phase A):

  • State 1: (Low, Low)
  • State 2: (Low, High)
  • State 3: (High, High)
  • State 4: (High, Low)

A full cycle of the encoder (e.g., one slot passing the sensor in an optical encoder) causes transitions through these four states.

%%{init: {'theme': 'default', 'flowchart': {'curve': 'basis', 'nodeSpacing': 60, 'rankSpacing': 50}, 'fontFamily': 'Open Sans'}}%%
graph TD
    %% State Nodes
    S00(["State 00<br/>(B=0, A=0)"])
    S01(["State 01<br/>(B=0, A=1)"])
    S11(["State 11<br/>(B=1, A=1)"])
    S10(["State 10<br/>(B=1, A=0)"])

    %% CW Rotation
    S00 -- "+1 ⟳" --> S01
    S01 -- "+1 ⟳" --> S11
    S11 -- "+1 ⟳" --> S10
    S10 -- "+1 ⟳" --> S00

    %% CCW Rotation
    S00 -- "-1 ⟲" --> S10
    S10 -- "-1 ⟲" --> S11
    S11 -- "-1 ⟲" --> S01
    S01 -- "-1 ⟲" --> S00

    %% Link Styles
    linkStyle 0,1,2,3 stroke:#059669,stroke-width:2.5px;
    linkStyle 4,5,6,7 stroke:#D97706,stroke-width:2.5px; 

    %% Node Styling
    classDef stateNode fill:#DBEAFE,stroke:#2563EB,stroke-width:2px,color:#1E40AF,font-size:14px,font-weight:bold;
    class S00,S01,S11,S10 stateNode;

    %% Title
    classDef titleNode fill:none,stroke:none,color:#000000,font-size:18px,font-weight:bold;
    

We can choose to count pulses at different points in this cycle, leading to different resolutions:

  1. 1x Decoding (Single-Edge Counting):
    • Count only one edge (e.g., rising edge) of one signal (e.g., Phase A).
    • The state of the other signal (Phase B) at that moment determines the direction.
    • Example: If counting rising edges of A:
      • If B is low when A rises: Increment (CW).
      • If B is high when A rises: Decrement (CCW).
    • This gives N pulses per revolution, where N is the encoder’s native pulses per revolution (PPR).
  2. 2x Decoding (Dual-Edge Counting on One Channel):
    • Count both rising and falling edges of one signal (e.g., Phase A).
    • The state of Phase B relative to Phase A determines direction.
    • Example:
      • A rises, B low: +1
      • A falls, B high: +1 (still same direction)
      • A rises, B high: -1
      • A falls, B low: -1 (still same direction)
    • This gives 2N pulses per revolution.
  3. 4x Decoding (All Edges Counting):
    • Count every edge transition on both Phase A and Phase B signals. Each state change (00->01, 01->11, 11->10, 10->00) is counted.
    • This provides the highest resolution, giving 4N pulses per revolution.
    • Implementing 4x decoding with a single PCNT unit channel (one edge input, one level input) can be tricky. Often, it’s implemented by using two PCNT channels/units (one for A edges, one for B edges, and software logic) or by more sophisticated hardware. However, the ESP32 PCNT can be configured to approximate 2x or achieve a form of 4x by carefully setting edge and level actions, or by using two channels where each channel monitors edges of one phase and the other phase acts as the level control. The most straightforward use of a single PCNT channel is for 1x or 2x decoding.

The ESP32’s PCNT module is well-suited for 1x and 2x decoding using a single channel, where one encoder signal (e.g., Phase A) is connected to the PCNT’s pulse input pin (edge_gpio_num), and the other signal (e.g., Phase B) is connected to the control input pin (level_gpio_num).

Configuring PCNT for Quadrature Encoders

As discussed in Chapter 165, the PCNT channel behavior is set by:

  • pcnt_channel_set_edge_action(channel, pos_action, neg_action): Defines action on positive/negative edges of edge_gpio_num.
  • pcnt_channel_set_level_action(channel, high_action, low_action): Defines how the state of level_gpio_num modifies the edge actions.
Decoding Mode Principle Resolution (per PPR) PCNT Implementation Notes
1x Decoding Counts one edge (e.g., rising) of Phase A only. Phase B determines direction. N pulses Simple. Use EDGE_ACTION_INCREASE on positive edge and HOLD on negative. Level actions on B provide direction.
2x Decoding Counts both rising and falling edges of Phase A. Phase B determines direction. 2N pulses Good balance. Use INCREASE on positive and DECREASE on negative edges. Level actions on B provide correct direction. (Most common single-channel method)
4x Decoding Counts every state change (rising and falling edges of both Phase A and Phase B). 4N pulses Highest resolution. Hard to achieve with one PCNT channel. Typically requires two PCNT channels (one per phase) or complex software logic on top of a 2x setup.

%%{init: {'fontFamily': 'Open Sans'}}%%
flowchart TD
    A(Start: Configure Encoder) --> B["pcnt_new_unit()<br><i>Set high/low limits, enable accum_count</i>"];
    B --> C["pcnt_new_channel()<br><i>Assign Phase A to edge_gpio,<br>Phase B to level_gpio</i>"];
    C --> D{"Select Decoding Mode"};
    D -- "1x Mode" --> E["pcnt_channel_set_edge_action()<br><i>Pos: INCREASE, Neg: HOLD</i>"];
    D -- "2x Mode" --> F["pcnt_channel_set_edge_action()<br><i>Pos: INCREASE, Neg: DECREASE</i>"];
    E --> G;
    F --> G;
    G["pcnt_channel_set_level_action()<br><i>High: INVERSE, Low: KEEP</i>"];
    G --> H["pcnt_unit_set_glitch_filter()<br><i>Crucial for debouncing!</i>"];
    H --> I["pcnt_unit_enable()"];
    I --> J["pcnt_unit_clear_count()"];
    J --> K["pcnt_unit_start()"];
    K --> L(End: Ready to Read);

    %% Styling
    classDef startNode fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6;
    classDef processNode fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF;
    classDef decisionNode fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E;
    classDef criticalNode fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B;
    classDef endNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46;

    class A,L startNode;
    class B,C,E,F,G,I,J,K processNode;
    class D decisionNode;
    class H criticalNode;
    class L endNode;

For 1x Decoding (Counting on Positive Edges of Phase A):

  • Connect Phase A to edge_gpio_num.
  • Connect Phase B to level_gpio_num.
  • Desired logic:
    • If A rises and B is LOW: Increment count (e.g., CW direction).
    • If A rises and B is HIGH: Decrement count (e.g., CCW direction).
    • Ignore falling edges of A.
  • PCNT Configuration:
    • pcnt_channel_set_edge_action(channel, PCNT_CHANNEL_EDGE_ACTION_INCREASE, PCNT_CHANNEL_EDGE_ACTION_HOLD);
      • pos_action = PCNT_CHANNEL_EDGE_ACTION_INCREASE: On A’s positive edge, default action is to increase.
      • neg_action = PCNT_CHANNEL_EDGE_ACTION_HOLD: On A’s negative edge, do nothing.
    • pcnt_channel_set_level_action(channel, PCNT_CHANNEL_LEVEL_ACTION_INVERSE, PCNT_CHANNEL_LEVEL_ACTION_KEEP);
      • high_action = PCNT_CHANNEL_LEVEL_ACTION_INVERSE: If B (level input) is HIGH, invert the edge action. So, INCREASE becomes DECREASE.
      • low_action = PCNT_CHANNEL_LEVEL_ACTION_KEEP: If B (level input) is LOW, keep the original edge action. So, INCREASE remains INCREASE.

This configuration achieves:

  • A positive edge, B low: INCREASE (original) because level action is KEEP.
  • A positive edge, B high: DECREASE (inverted from INCREASE) because level action is INVERSE.
  • A negative edge (any B level): HOLD.

For 2x Decoding (Counting on Both Edges of Phase A):

  • Connect Phase A to edge_gpio_num.
  • Connect Phase B to level_gpio_num.
  • Desired logic (example for one direction, CW):
    • A rises, B is LOW: Increment.
    • A falls, B is HIGH: Increment.
    • (And the reverse for CCW)
  • PCNT Configuration:
    • pcnt_channel_set_edge_action(channel, PCNT_CHANNEL_EDGE_ACTION_INCREASE, PCNT_CHANNEL_EDGE_ACTION_DECREASE);
      • pos_action = PCNT_CHANNEL_EDGE_ACTION_INCREASE: On A’s positive edge, default is increase.
      • neg_action = PCNT_CHANNEL_EDGE_ACTION_DECREASE: On A’s negative edge, default is decrease.
    • pcnt_channel_set_level_action(channel, PCNT_CHANNEL_LEVEL_ACTION_INVERSE, PCNT_CHANNEL_LEVEL_ACTION_KEEP);
      • high_action = PCNT_CHANNEL_LEVEL_ACTION_INVERSE: If B is HIGH, invert the edge action.
      • low_action = PCNT_CHANNEL_LEVEL_ACTION_KEEP: If B is LOW, keep the original edge action.

This configuration achieves:

  • A positive edge, B low: INCREASE (original INCREASE, B_low KEEP).
  • A positive edge, B high: DECREASE (original INCREASE, B_high INVERSE).
  • A negative edge, B low: DECREASE (original DECREASE, B_low KEEP).
  • A negative edge, B high: INCREASE (original DECREASE, B_high INVERSE).

This set of actions correctly decodes typical quadrature signals for 2x resolution.

Debouncing and Filtering

Mechanical rotary encoders, due to their physical contacts, can produce noisy signals or “bouncing” during state transitions. Optical encoders are generally cleaner but can still be affected by environmental noise.

The PCNT module’s built-in glitch filter is essential here:

  • pcnt_unit_set_glitch_filter(pcnt_unit, &filter_config);
  • filter_config.max_glitch_ns: Pulses shorter than this duration (in nanoseconds) on the input pins will be ignored. This value needs to be tuned based on the encoder’s characteristics and expected rotation speed. Too short, and noise passes; too long, and valid fast pulses might be missed. Typical values for mechanical encoders might be a few microseconds (e.g., 1000 to 10000 ns).

Practical Examples

Let’s implement rotary encoder interfacing. Ensure your ESP-IDF project is set up as in Chapter 165, with driveresp_log, and esp_timer components.

Project Setup

  1. Create/Open an ESP-IDF project.
  2. main/CMakeLists.txt:idf_component_register(SRCS "main.c" INCLUDE_DIRS "." REQUIRES driver esp_log esp_timer freertos) # freertos for tasks/semaphores if needed

Example 1: Quadrature Encoder (2x Decoding) and Position Display

This example demonstrates 2x decoding of a rotary encoder and prints the accumulated count.

Hardware Setup:

  • Encoder Phase A -> GPIO4 (ENCODER_PHASE_A_GPIO)
  • Encoder Phase B -> GPIO5 (ENCODER_PHASE_B_GPIO)
  • Encoder Common (C/GND) -> ESP32 GND
  • Pull-up resistors (e.g., 4.7kΩ or 10kΩ) from Phase A to 3.3V and Phase B to 3.3V (if not built into the encoder module).

main/main.c:

C
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/pulse_cnt.h"
#include "driver/gpio.h"
#include "esp_log.h"

static const char *TAG = "ENCODER_EXAMPLE";

#define ENCODER_PHASE_A_GPIO    4
#define ENCODER_PHASE_B_GPIO    5
#define PCNT_HIGH_LIMIT         10000 // Arbitrary high limit for example
#define PCNT_LOW_LIMIT          -10000// Arbitrary low limit for example
#define ENCODER_GLITCH_FILTER_NS 1000 // Filter out glitches shorter than 1us (1000 ns)

static pcnt_unit_handle_t pcnt_unit = NULL;

// Optional: Event callback for limits (runs in ISR context)
static bool IRAM_ATTR pcnt_event_callback(pcnt_unit_handle_t unit, const pcnt_watch_event_data_t *edata, void *user_ctx)
{
    ESP_DRAM_LOGI(TAG, "PCNT Watch Event: %d", edata->watch_point_event_type);
    // In a real app, you might signal a task or update a shared variable carefully
    return false; // No task woken by this ISR
}

void initialize_encoder(void)
{
    ESP_LOGI(TAG, "Initializing PCNT for Quadrature Encoder (2x decoding)");

    // 1. Configure PCNT Unit
    pcnt_unit_config_t unit_config = {
        .high_limit = PCNT_HIGH_LIMIT,
        .low_limit = PCNT_LOW_LIMIT,
        .flags.accum_count = true, // Allow counter to accumulate beyond limits (wrap around)
    };
    ESP_ERROR_CHECK(pcnt_new_unit(&unit_config, &pcnt_unit));

    // 2. Configure PCNT Channel
    pcnt_channel_config_t channel_config = {
        .edge_gpio_num = ENCODER_PHASE_A_GPIO,
        .level_gpio_num = ENCODER_PHASE_B_GPIO,
        .flags.invert_edge_input = false,  // Set true if Phase A logic is inverted
        .flags.invert_level_input = false, // Set true if Phase B logic is inverted
    };
    pcnt_channel_handle_t pcnt_channel = NULL;
    ESP_ERROR_CHECK(pcnt_new_channel(pcnt_unit, &channel_config, &pcnt_channel));

    // 3. Set Edge and Level Actions for 2x Quadrature Decoding
    // Action on Phase A's positive edge
    // Action on Phase A's negative edge
    ESP_ERROR_CHECK(pcnt_channel_set_edge_action(pcnt_channel,
                                                 PCNT_CHANNEL_EDGE_ACTION_INCREASE,  // Default action on positive edge
                                                 PCNT_CHANNEL_EDGE_ACTION_DECREASE)); // Default action on negative edge

    // How Phase B's level modifies the above actions
    ESP_ERROR_CHECK(pcnt_channel_set_level_action(pcnt_channel,
                                                  PCNT_CHANNEL_LEVEL_ACTION_INVERSE, // When B is high, invert the A edge action
                                                  PCNT_CHANNEL_LEVEL_ACTION_KEEP));  // When B is low, keep the A edge action
    /*
     * This results in:
     * A_pos_edge, B_low:  INCREASE (original INC, B_low keeps)
     * A_pos_edge, B_high: DECREASE (original INC, B_high inverts)
     * A_neg_edge, B_low:  DECREASE (original DEC, B_low keeps)
     * A_neg_edge, B_high: INCREASE (original DEC, B_high inverts)
     * This is standard 2x quadrature decoding.
     */

    // 4. Add Glitch Filter
    pcnt_glitch_filter_config_t filter_config = {
        .max_glitch_ns = ENCODER_GLITCH_FILTER_NS,
    };
    ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(pcnt_unit, &filter_config));
    ESP_LOGI(TAG, "Encoder glitch filter enabled: %d ns", ENCODER_GLITCH_FILTER_NS);

    // Optional: Register event callbacks for limits
    // pcnt_event_callbacks_t cbs = {
    //     .on_reach_high_limit = pcnt_event_callback,
    //     .on_reach_low_limit = pcnt_event_callback,
    // };
    // ESP_ERROR_CHECK(pcnt_unit_register_event_callbacks(pcnt_unit, &cbs, NULL));

    // 5. Enable, Clear, and Start PCNT Unit
    ESP_ERROR_CHECK(pcnt_unit_enable(pcnt_unit));
    ESP_ERROR_CHECK(pcnt_unit_clear_count(pcnt_unit));
    ESP_ERROR_CHECK(pcnt_unit_start(pcnt_unit));
    ESP_LOGI(TAG, "PCNT unit started for quadrature encoder");
}

void app_main(void)
{
    initialize_encoder();

    int current_encoder_count = 0;
    int last_displayed_count = -1; // Initialize to a value that will trigger first print

    while (1) {
        ESP_ERROR_CHECK(pcnt_unit_get_count(pcnt_unit, &current_encoder_count));
        if (current_encoder_count != last_displayed_count) {
            ESP_LOGI(TAG, "Encoder Count: %d", current_encoder_count);
            last_displayed_count = current_encoder_count;
        }
        vTaskDelay(pdMS_TO_TICKS(100)); // Poll and print every 100ms if changed
    }
}

Build, Flash, and Run:

  1. Connect your encoder as described.
  2. Build the project (idf.py build).
  3. Flash to your ESP32 (idf.py -p /dev/ttyUSBX flash).
  4. Monitor the output (idf.py monitor).
  5. Rotate the encoder shaft. You should see the “Encoder Count” log messages changing, incrementing for one direction and decrementing for the other. The count should change by 2 for each “click” or detent of a typical mechanical encoder if it has one pulse per detent per channel.

Example 2: Encoder with Button Press Reset and Index (Z) Pulse Handling

Many rotary encoders include a push-button switch. Let’s use this to reset the encoder count. We’ll also simulate handling an Index (Z) pulse using another GPIO interrupt.

Hardware Setup (Addition to Example 1):

  • Encoder Switch Pin -> GPIO18 (ENCODER_SWITCH_GPIO) (with external pull-up to 3.3V, switch connects to GND).
  • Encoder Index (Z) Pin (if available) -> GPIO19 (ENCODER_INDEX_Z_GPIO) (with external pull-up, Z pulse typically goes low).

main/main.c:

C
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h" // For ISR to task communication
#include "driver/pulse_cnt.h"
#include "driver/gpio.h"
#include "esp_log.h"

static const char *TAG = "ENCODER_ADV_EXAMPLE";

// PCNT Configuration (same as Example 1)
#define ENCODER_PHASE_A_GPIO    4
#define ENCODER_PHASE_B_GPIO    5
#define PCNT_HIGH_LIMIT         32000 // Wider range
#define PCNT_LOW_LIMIT          -32000
#define ENCODER_GLITCH_FILTER_NS 1000

// GPIO for Switch and Index
#define ENCODER_SWITCH_GPIO     GPIO_NUM_18
#define ENCODER_INDEX_Z_GPIO    GPIO_NUM_19 // Optional

static pcnt_unit_handle_t pcnt_unit = NULL;
static QueueHandle_t gpio_evt_queue = NULL;

// ISR handler for Switch and Index GPIOs
static void IRAM_ATTR gpio_isr_handler(void *arg)
{
    uint32_t gpio_num = (uint32_t)arg;
    xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}

// Task to handle GPIO events (button press, index pulse)
static void gpio_event_task(void *arg)
{
    uint32_t io_num;
    for (;;) {
        if (xQueueReceive(gpio_evt_queue, &io_num, portMAX_DELAY)) {
            if (io_num == ENCODER_SWITCH_GPIO) {
                // Debounce might be needed here in a real app if not handled by hardware/filter
                ESP_LOGI(TAG, "Encoder switch pressed! Resetting PCNT count.");
                if (pcnt_unit) {
                    ESP_ERROR_CHECK(pcnt_unit_clear_count(pcnt_unit));
                }
            } else if (io_num == ENCODER_INDEX_Z_GPIO) {
                ESP_LOGI(TAG, "Encoder Index (Z) pulse detected! PCNT count could be referenced or reset.");
                // Example: Reset on Z pulse. In a real system, you might record this event
                // or use it to calibrate a multi-turn counter.
                if (pcnt_unit) {
                     ESP_ERROR_CHECK(pcnt_unit_clear_count(pcnt_unit));
                     ESP_LOGI(TAG,"PCNT count reset by Z pulse.");
                }
            }
        }
    }
}

void initialize_encoder_with_switch_and_index(void)
{
    ESP_LOGI(TAG, "Initializing PCNT and GPIOs for Encoder with Switch/Index");

    // --- PCNT Initialization (same as Example 1) ---
    pcnt_unit_config_t unit_config = {
        .high_limit = PCNT_HIGH_LIMIT,
        .low_limit = PCNT_LOW_LIMIT,
        .flags.accum_count = true,
    };
    ESP_ERROR_CHECK(pcnt_new_unit(&unit_config, &pcnt_unit));

    pcnt_channel_config_t channel_config = {
        .edge_gpio_num = ENCODER_PHASE_A_GPIO,
        .level_gpio_num = ENCODER_PHASE_B_GPIO,
    };
    pcnt_channel_handle_t pcnt_channel = NULL;
    ESP_ERROR_CHECK(pcnt_new_channel(pcnt_unit, &channel_config, &pcnt_channel));

    ESP_ERROR_CHECK(pcnt_channel_set_edge_action(pcnt_channel,
                                                 PCNT_CHANNEL_EDGE_ACTION_INCREASE,
                                                 PCNT_CHANNEL_EDGE_ACTION_DECREASE));
    ESP_ERROR_CHECK(pcnt_channel_set_level_action(pcnt_channel,
                                                  PCNT_CHANNEL_LEVEL_ACTION_INVERSE,
                                                  PCNT_CHANNEL_LEVEL_ACTION_KEEP));
    pcnt_glitch_filter_config_t filter_config = { .max_glitch_ns = ENCODER_GLITCH_FILTER_NS };
    ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(pcnt_unit, &filter_config));
    ESP_ERROR_CHECK(pcnt_unit_enable(pcnt_unit));
    ESP_ERROR_CHECK(pcnt_unit_clear_count(pcnt_unit));
    ESP_ERROR_CHECK(pcnt_unit_start(pcnt_unit));
    ESP_LOGI(TAG, "PCNT unit for encoder started.");

    // --- GPIO Initialization for Switch and Index ---
    gpio_config_t io_conf = {};
    // Interrupt on negative edge (falling edge for pull-up active low switch/Z-pulse)
    io_conf.intr_type = GPIO_INTR_NEGEDGE; 
    // Bit mask of the pins
    io_conf.pin_bit_mask = (1ULL << ENCODER_SWITCH_GPIO) | (1ULL << ENCODER_INDEX_Z_GPIO);
    // Set as input mode
    io_conf.mode = GPIO_MODE_INPUT;
    // Enable pull-up mode
    io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
    io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
    gpio_config(&io_conf);

    // Create a queue to handle gpio events from ISR
    gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));
    // Start task to handle GPIO events
    xTaskCreate(gpio_event_task, "gpio_event_task", 2048, NULL, 10, NULL);

    // Install GPIO ISR service
    ESP_ERROR_CHECK(gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT));
    // Hook ISR handler for specific GPIO pins
    ESP_ERROR_CHECK(gpio_isr_handler_add(ENCODER_SWITCH_GPIO, gpio_isr_handler, (void *)ENCODER_SWITCH_GPIO));
    ESP_ERROR_CHECK(gpio_isr_handler_add(ENCODER_INDEX_Z_GPIO, gpio_isr_handler, (void *)ENCODER_INDEX_Z_GPIO));
    
    ESP_LOGI(TAG, "GPIO ISR for switch and index configured.");
}

void app_main(void)
{
    initialize_encoder_with_switch_and_index();

    int current_encoder_count = 0;
    int last_displayed_count = 0; 

    while (1) {
        if (pcnt_unit) { // Check if pcnt_unit is initialized
            ESP_ERROR_CHECK(pcnt_unit_get_count(pcnt_unit, &current_encoder_count));
            if (current_encoder_count != last_displayed_count) {
                ESP_LOGI(TAG, "Encoder Count: %d", current_encoder_count);
                last_displayed_count = current_encoder_count;
            }
        }
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

Build, Flash, and Run:

  1. Connect the encoder, switch, and optionally the Z-pulse line.
  2. Build, flash, and monitor.
  3. Rotate the encoder. The count should change.
  4. Press the encoder switch. The count should reset to 0.
  5. If Z-pulse is connected, rotating past the index point should also trigger a log and reset the count.

Tip: Mechanical switches (like encoder buttons) also suffer from bouncing. The example above doesn’t include software debouncing for the GPIO interrupt for simplicity, but in a production application, you would add a delay or a more sophisticated debouncing algorithm within gpio_event_task before acting on the switch press. The PCNT glitch filter only applies to PCNT inputs, not general GPIOs.

Variant Notes

The PCNT module’s core functionality for encoder interfacing is largely consistent across ESP32 variants that feature it (ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, ESP32-H2).

  • Number of PCNT Units:
    • ESP32: 8 units. Allows for up to 8 single-channel encoders (or 4 encoders if using two channels per encoder for advanced decoding, though less common with the standard API).
    • ESP32-S2, S3, C3, C6, H2: 4 units. Allows for up to 4 single-channel encoders.This is the primary limiting factor if you need to interface multiple encoders.
  • API and Configuration: The ESP-IDF driver/pulse_cnt.h API used in the examples (pcnt_new_unitpcnt_new_channel, etc.) is designed to be portable across these variants.
  • Glitch Filter: The capabilities and configuration of the glitch filter are generally the same. The filter’s time base is derived from the APB clock, so its exact resolution in nanoseconds might have slight variations depending on the APB clock frequency of the specific chip, but max_glitch_ns abstracts this.
  • GPIO Matrix: All these variants have a flexible GPIO matrix, meaning most GPIOs can be routed to PCNT inputs. Always consult the specific variant’s datasheet for any pin limitations or recommendations for peripheral use.

For most common encoder applications (one or two encoders), any of these variants will perform well. The choice might depend on other factors like the number of other peripherals needed, processing power, or specific connectivity features.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
Count goes the wrong way
Phase A/B swapped or logic inverted.
Encoder increments when it should decrement, and vice-versa. Easiest Fix: Swap the GPIO pin numbers for Phase A and Phase B in your code.
OR
Code Fix: In pcnt_channel_config_t, try setting .flags.invert_level_input = true.
Count jumps by 2 (or 4) instead of 1
Misunderstanding PPR vs. decoding mode.
For one “click” (detent) of the encoder, the count changes by more than expected. This is often correct behavior. A 2x or 4x decoding will give more counts than the encoder’s PPR. If a single detent produces one full cycle on A/B, 2x decoding will yield 2 counts. If you need 1 count per detent, either use 1x decoding or divide the result in software.
Missing Pull-Up Resistors
Encoder has open-drain/collector outputs.
Count is erratic or doesn’t work at all. May work if you touch the wires (due to body capacitance). Check encoder datasheet. If it requires pull-ups, add them. Use external 4.7kΩ to 10kΩ resistors to 3.3V for best results. Internal ESP32 pull-ups might work but can be too weak.
Inadequate Glitch Filter Setting
Filter value is too low or too high.
Too low: Multiple counts per detent (noise gets through).
Too high: Missed counts at high rotation speeds.
Always enable the filter. Start with 1000ns (1us) for max_glitch_ns and increase if you still see noise. For fast optical encoders, you may need a smaller value.
GPIO Interrupt for Z/Switch not working
ISR service not installed or pin misconfigured.
Pressing the button or passing the index pulse does nothing. 1. Ensure gpio_install_isr_service() is called once in your app.
2. Check that the GPIO is configured as an input with the correct pull-up/pull-down setting.
3. Verify the interrupt trigger type (GPIO_INTR_NEGEDGE for active-low signals with pull-ups).

Exercises

  1. 1x Decoding Mode:
    • Modify Example 1 to implement 1x quadrature decoding (counting only on positive edges of Phase A, with direction determined by Phase B).
    • Verify that the count increments/decrements by 1 for each pulse cycle of the encoder (which might correspond to one or more detents depending on the encoder type).
  2. Encoder-Controlled LED Brightness:
    • Interface a rotary encoder using PCNT.
    • Interface an LED using the LEDC (PWM) peripheral (refer to Chapter 145).
    • Read the encoder count and map it to the LED’s duty cycle to control its brightness. For example, a count from 0 to 255 could map directly to the PWM duty. Ensure the count stays within reasonable bounds for PWM.
  3. Calculating RPM (Revolutions Per Minute):
    • Using the encoder setup from Example 1 (2x decoding).
    • In a periodic task (e.g., using esp_timer every second):
      • Read the current PCNT count.
      • Calculate the change in count since the last reading (delta_count).
      • Clear the PCNT count (or subtract the previous reading from the current to get delta if not clearing).
      • Given the encoder’s Pulses Per Revolution (PPR) and the decoding mode (2x), calculate the number of revolutions: revolutions = delta_count / (PPR * 2).
      • Calculate RPM: RPM = (revolutions / time_interval_seconds) * 60.
      • Log the RPM.
    • You’ll need to know your encoder’s native PPR.
  4. Limited Range Counter with Saturation:
    • Configure the PCNT unit with flags.accum_count = false (default).
    • Set high_limit to 100 and low_limit to 0.
    • Observe that the counter stops counting (saturates) when it reaches 0 or 100, regardless of further encoder rotation in that direction.
    • Optionally, use pcnt_unit_register_event_callbacks to detect on_reach_high_limit and on_reach_low_limit events and log them.

Summary

  • Incremental quadrature encoders use two out-of-phase signals (Phase A, Phase B) to provide rotational position and direction information, and an optional Index (Z) pulse per revolution.
  • The PCNT module is ideal for decoding these signals by configuring one signal as the edge input and the other as the level control input.
  • Decoding modes (1x, 2x, 4x) offer different resolutions by counting different combinations of edges. 1x and 2x modes are straightforward with a single PCNT channel.
  • Proper configuration of pcnt_channel_set_edge_action() and pcnt_channel_set_level_action() is crucial for correct direction sensing and decoding.
  • The glitch filter (pcnt_unit_set_glitch_filter()) is vital for handling noise from mechanical encoders.
  • The Index (Z) pulse is typically handled with a separate GPIO interrupt to reset or reference the PCNT count.
  • ESP32 variants offer multiple PCNT units (4 or 8), allowing several encoders to be interfaced.
  • Common issues involve wiring, pull-up resistors, filter settings, and understanding decoding logic.

Further Reading

  • ESP-IDF Pulse Counter Documentation:
    • Pulse Counter (PCNT) API Guide (Check for your specific variant if needed, e.g., esp32s3).
    • The esp-idf/examples/peripherals/pcnt/rotary_encoder example in your ESP-IDF installation is a key reference.
  • Rotary Encoder Basics:
    • Many electronics hobbyist sites and manufacturers (e.g., Bourns, Alps Alpine, SparkFun, Adafruit) provide excellent tutorials and datasheets on rotary encoders. Search for “quadrature encoder working principle.”
  • ESP32 Series Technical Reference Manuals:
    • For the most in-depth hardware details of the PCNT module, consult the TRM for your specific ESP32 variant available on the Espressif Documentation Page.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top