Skip to content

Rotary Encoder ​

Feature Overview ​

Rotary encoders detect rotation direction and angle, supporting forward/reverse counting and button functions. Suitable for volume adjustment, menu navigation, position control, and other scenarios.

Driver Implementation Status: ✅ Full driver implemented (v2.1)

Core Features ​

  • ✅ GPIO Interrupt Mode - Compatible with all ESP32 series chips (ESP32/ESP32-S3/ESP32-C3/ESP32-C6)
  • ✅ Quadrature Decoding - A/B phases 90 degrees apart for precise direction detection
  • ✅ Real-time Counter Reading - Get counter status via readPin() API
  • ✅ Counter Reset - Reset counter to zero via writePin() API with STATE_LOW
  • ✅ Interrupt Handling Optimization - Uses IRAM_ATTR attribute to ensure execution in IRAM for faster response
  • ✅ Thread-Safe Design - Uses volatile int32_t for interrupt context safety
  • ✅ Counter Storage - std::map<String, volatile int32_t> manages multiple encoder instances

💡 Future Optimization: ESP32/ESP32-S3 can be upgraded to PCNT hardware counters (higher precision, no CPU intervention required)

Working Principle ​

Quadrature Decoding Mechanism ​

The encoder uses A/B two-phase quadrature signals for direction judgment:

Clockwise Rotation (CW):
  Phase A: ─┐   ┌─┐   ┌─┐   ┌─
            └───┘ └───┘ └───
  Phase B: ───┐   ┌─┐   ┌─┐   ┌
              └───┘ └───┘ └───┘
  Direction: When Phase A triggers interrupt, Phase B is HIGH → count+1

Counter-Clockwise Rotation (CCW):
  Phase A: ─┐   ┌─┐   ┌─┐   ┌─
            └───┘ └───┘ └───
  Phase B:     ┌─┐   ┌─┐   ┌─┐
            ───┘ └───┘ └───┘ └
  Direction: When Phase A triggers interrupt, Phase B is LOW → count-1

Interrupt Handling Flow ​

cpp
void IRAM_ATTR PeripheralManager::handleEncoderInterrupt(void* arg) {
    // 1. Parse peripheral configuration
    PeripheralConfig* config = static_cast<PeripheralConfig*>(arg);
    if (!config || config->type != PeripheralType::ENCODER) return;

    // 2. Read A/B phase states
    uint8_t pinA = config->pins[0];
    uint8_t pinB = config->pins[1];
    int stateA = digitalRead(pinA);
    int stateB = digitalRead(pinB);

    // 3. Determine rotation direction
    // Phase B HIGH = clockwise, Phase B LOW = counter-clockwise
    int32_t direction = stateB ? 1 : -1;

    // 4. Update counter (interrupt-safe)
    String peripheralId = config->id;
    auto& pm = PeripheralManager::getInstance();
    auto it = pm.encoderCounters.find(peripheralId);
    if (it != pm.encoderCounters.end()) {
        it->second += direction;
    } else {
        pm.encoderCounters[peripheralId] = direction;
    }
}

Initialization Flow ​

cpp
// 1. Validate pin count (at least 2: Phase A and Phase B)
if (config.pinCount < 2) return false;

// 2. Configure GPIO as pull-up input mode
pinMode(pinA, INPUT_PULLUP);
pinMode(pinB, INPUT_PULLUP);

// 3. Initialize counter
if (config.params.encoder.useInterrupt) {
    encoderCounters[config.id] = 0;

    // 4. Attach interrupt to Phase A (trigger on both rising and falling edges)
    attachInterruptArg(pinA, handleEncoderInterrupt,
                       const_cast<PeripheralConfig*>(&config), CHANGE);
}

Supported Peripheral Types ​

Typetype valueDescription
ENCODER43Rotary encoder (dual-channel A/B phases)

Hardware Wiring ​

Encoder PinFunctionDescription
CLK (A)GPIOPhase A signal
DT (B)GPIOPhase B signal
SWGPIO (optional)Button signal (can be used as separate input)
VCC3.3VPower
GNDGNDGround

Usage Methods ​

Rotary encoders require configuring both A/B signals. Complete configuration flow:

Step 1: Log in to Device Management Page ​

  1. Enter ESP32 IP address in browser
  2. After logging in, navigate to Peripheral Configuration

Step 2: Add Rotary Encoder Peripheral ​

  1. Click the Add Peripheral button

  2. Fill in the configuration:

    FieldExampleDescription
    Peripheral IDencoder1Unique identifier
    NameRotary EncoderDisplay name
    Peripheral TypeRotary Encoder (type: 43)Dual-channel A/B phases
    CLK Pin (A)32Phase A signal
    DT Pin (B)33Phase B signal
    Pulses Per Revolution20Typically 20
    Use InterrupttrueRecommended
  3. Click Save

Step 3: Verify Configuration ​

  1. Find the newly added peripheral in the peripheral list
  2. Click the Test button
  3. Rotate the encoder to observe counter value changes

💡 Tip: If the encoder has a button pin (SW pin), it needs to be separately configured as GPIO digital input (type: 13)


Method 2: JSON Configuration File ​

Add the configuration to the peripherals array in data/config/peripherals.json:

json
{
  "id": "encoder1",
  "name": "Rotary Encoder",
  "type": 43,
  "enabled": false,
  "pins": [32, 33],
  "params": {
    "resolution": 20,
    "useInterrupt": true
  }
}

Parameter Description ​

ParameterDescription
resolutionPulses per revolution (typically 20)
useInterruptWhether to use interrupt mode (recommended true)

pins[0] = CLK (Phase A) pin, pins[1] = DT (Phase B) pin

API Reference ​

Read Encoder Status ​

cpp
// Function signature
GPIOState PeripheralManager::readPin(const String& peripheralId);

// Usage example
GPIOState state = pm.readPin("encoder_01");

// Return value description:
// - STATE_HIGH:  Counter is non-zero (rotation activity detected)
// - STATE_LOW:   Counter is zero (no rotation or has been reset)
// - STATE_UNDEFINED: Peripheral does not exist or is not initialized

Underlying Implementation:

cpp
// Encoder: returns count value (converted to GPIOState, HIGH means non-zero count)
if (config->type == PeripheralType::ENCODER) {
    auto it = encoderCounters.find(peripheralId);
    if (it == encoderCounters.end()) {
        return GPIOState::STATE_UNDEFINED;
    }
    return it->second != 0 ? GPIOState::STATE_HIGH : GPIOState::STATE_LOW;
}

Reset Encoder Counter ​

cpp
// Function signature
bool PeripheralManager::writePin(const String& peripheralId, GPIOState state);

// Usage example
pm.writePin("encoder_01", GPIOState::STATE_LOW);  // Reset counter to zero

// Return value:
// - true:  Reset successful
// - false: Peripheral does not exist

Underlying Implementation:

cpp
// Encoder: supports resetting counter (writing LOW means reset)
if (config->type == PeripheralType::ENCODER) {
    if (state == GPIOState::STATE_LOW) {
        encoderCounters[peripheralId] = 0;
        LOG_INFOF("Peripheral Manager: Encoder '%s' counter reset to 0",
                  peripheralId.c_str());
    }
    return true;
}

Data Reporting Format ​

Encoder count values are reported via MQTT:

json
[{"id": "encoder1", "value": "42"}]

Rotary encoder data represents cumulative rotation angle values.

Peripheral Execution Linkage ​

Web Interface Configuration Steps ​

Encoder as Platform Trigger Source

  1. Click Peripheral Execution in the left menu
  2. Click the Add Rule button
  3. Configure platform trigger conditions:
    • Trigger Type: Platform Trigger
    • Trigger Source: encoder1
    • Comparison Operation: Greater Than
    • Comparison Value: 100
  4. Add actions (e.g., control lights, buzzer, etc.)
  5. Click Save

Encoder Button as Event Trigger Source

  1. Configure encoder SW pin as GPIO digital input (type: 13)
  2. Configure event trigger conditions:
    • Trigger Type: Event Trigger
    • Event Source: encoder1_btn
    • Event Type: button_click
  3. Add actions
  4. Click Save

💡 Tip: Interrupt mode is recommended over polling mode for faster rotation response


JSON Configuration Examples ​

As Platform Trigger Source Resource ​

Encoder count value changes can trigger linkage:

json
{
  "triggerType": 0,
  "triggerPeriphId": "encoder1",
  "operatorType": 2,
  "compareValue": "100"
}

Encoder Button Event ​

Configure the encoder SW pin as GPIO digital input (pull-up) to use button events:

json
{
  "id": "encoder1_btn",
  "name": "Encoder Button",
  "type": 13,
  "enabled": false,
  "pins": [25],
  "params": {
    "initialState": 0,
    "pwmChannel": 0,
    "pwmFrequency": 1000,
    "pwmResolution": 8,
    "defaultDuty": 0
  }
}

Test Coverage ​

Unit Tests (7) ​

Test CaseVerification Content
test_encoder_type_enum_valueEnum value validation (type=43)
test_encoder_config_validation_validValid configuration validation
test_encoder_config_validation_missing_pinsInsufficient pins detection
test_encoder_config_validation_zero_resolutionResolution=0 detection
test_encoder_data_transparencyData transparency verification
test_encoder_read_counterCounter read functionality
test_encoder_reset_counterCounter reset functionality

E2E Tests (2) ​

Test CaseVerification Content
test_e2e_encoder_peripheral_workflowComplete encoder workflow
test_e2e_encoder_mqtt_integrationEncoder MQTT integration

Integration Tests ​

  • ✅ MQTT linkage trigger
  • ✅ Complete counter read/write workflow
  • ✅ Interrupt response speed test
  • ✅ Multi-encoder instance concurrency test

Precautions ​

  1. Interrupt Mode: Interrupt mode is recommended; polling mode may lose counts at high rotation frequencies
  2. Pull-up Resistors: Encoders typically require pull-up resistors to ensure signal stability
  3. Pin Selection: Ensure GPIO pins that support interrupts are used
  4. Counter Overflow: Long-running applications should monitor counter overflow and handle it at the application layer when necessary
  5. Multiple Encoders: The system supports multiple encoder instances, each counting independently
  6. Thread Safety: Interrupt handler uses IRAM_ATTR to ensure response speed

Chip Compatibility ​

Chip ModelGPIO InterruptPCNT Hardware CounterDescription
ESP32✅ Supported✅ SupportedCan use PCNT optimization
ESP32-S3✅ Supported✅ SupportedCan use PCNT optimization
ESP32-C3✅ Supported❌ Not SupportedGPIO interrupt only
ESP32-C6✅ Supported❌ Not SupportedGPIO interrupt only

📌 Note: Current implementation uses GPIO interrupt mode, compatible with all ESP32 series. PCNT hardware counter support can be added for ESP32/ESP32-S3 in the future.

FastBee | fastbee.cn