Skip to main content

Encoders

Using rotary encoders (KY-040 / EC11) with an ESP32 to implement cockpit knobs for heading bugs, radio tuning, course selectors, and barometric pressure settings.

What is a Rotary Encoder?

A rotary encoder converts rotational motion into a digital signal. Unlike a potentiometer, it has no end-stops and produces an infinite stream of position changes. The two output channels (A and B) are 90° out of phase — this quadrature encoding lets the reading code determine both direction and speed of rotation.

TypeExampleSteps / revPush buttonCockpit use
Incremental (mechanical)KY-04020YesAll knobs — cheap, readily available
Incremental (mechanical)EC1120–30YesPanel-mount knobs with 6 mm flat shaft
Incremental (optical)HEDL-5540100–2000NoHigh-precision yoke / rudder position
Absolute (magnetic)AS5048A16 384 (14-bit)NoThrottle lever position, absolute angle

Quadrature decoding

Reading both A and B on every edge gives 4× resolution. When A rises and B is low → clockwise; when A rises and B is high → counter-clockwise. The Gray code state machine in the debounced example below handles all edge cases.

A (prev→now)B (prev→now)Direction
0→10Clockwise (CW)
1→01Clockwise (CW)
0→11Counter-clockwise (CCW)
1→00Counter-clockwise (CCW)
Wiring — KY-040
KY-040 Rotary Encoder GPIO 32 CLK (A) GPIO 33 DT (B) GPIO 25 SW (btn) 3.3 V + GND GND Quadrature A B CW →
KY-040 pinConnect toNotes
CLK (A)GPIO 32Primary quadrature channel
DT (B)GPIO 33Secondary quadrature channel
SWGPIO 25Push button — active LOW when pressed
+ (VCC)3.3 VKY-040 has on-board pull-ups
GNDGND
Arduino Code — Interrupt-Driven Reading

Always read encoders in an ISR (Interrupt Service Routine). Polling in loop() misses edges at typical human rotation speeds when other processing is happening. Use IRAM_ATTR on ESP32 so the ISR is placed in fast RAM.

const int ENC_A  = 32;   // CLK — any interrupt-capable pin
const int ENC_B  = 33;   // DT
const int ENC_SW = 25;   // push-button

volatile int  encoderPos  = 0;
volatile bool buttonPress = false;

void IRAM_ATTR onEncoderChange() {
    // Read both channels
    int a = digitalRead(ENC_A);
    int b = digitalRead(ENC_B);

    // A-channel edge: if B differs → CW, if B same → CCW
    if (a == HIGH) {
        encoderPos += (b == LOW) ? 1 : -1;
    }
}

void IRAM_ATTR onButtonPress() {
    buttonPress = true;   // handle in loop() — avoid delay in ISR
}

void setup() {
    pinMode(ENC_A,  INPUT_PULLUP);
    pinMode(ENC_B,  INPUT_PULLUP);
    pinMode(ENC_SW, INPUT_PULLUP);

    attachInterrupt(digitalPinToInterrupt(ENC_A), onEncoderChange, CHANGE);
    attachInterrupt(digitalPinToInterrupt(ENC_SW), onButtonPress,  FALLING);

    Serial.begin(115200);
}

void loop() {
    static int lastPos = 0;

    if (encoderPos != lastPos) {
        Serial.printf("Encoder: %d (delta %+d)\n", encoderPos, encoderPos - lastPos);
        lastPos = encoderPos;
    }

    if (buttonPress) {
        buttonPress = false;
        Serial.println("Button pressed — reset");
        encoderPos = 0;
    }

    delay(10);
}
Debounced Gray Code Decoder

Cheap mechanical encoders produce contact bounce that causes false counts. The Gray code state machine below only increments when a valid quadrature transition is detected — any noisy intermediate state is silently ignored.

// Debounce with state machine — handles cheap encoders that produce noise

const int ENC_A = 32;
const int ENC_B = 33;

volatile int  encoderPos = 0;
volatile uint8_t lastEncoded = 0;

void IRAM_ATTR onEncoderChange() {
    uint8_t a = digitalRead(ENC_A);
    uint8_t b = digitalRead(ENC_B);
    uint8_t encoded  = (a << 1) | b;
    uint8_t combined = (lastEncoded << 2) | encoded;

    // Gray code transitions for clockwise motion
    if (combined == 0b1101 || combined == 0b0100 ||
        combined == 0b0010 || combined == 0b1011) { encoderPos++; }

    // Gray code transitions for counter-clockwise motion
    if (combined == 0b1110 || combined == 0b0111 ||
        combined == 0b0001 || combined == 0b1000) { encoderPos--; }

    lastEncoded = encoded;
}

void setup() {
    pinMode(ENC_A, INPUT_PULLUP);
    pinMode(ENC_B, INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(ENC_A), onEncoderChange, CHANGE);
    attachInterrupt(digitalPinToInterrupt(ENC_B), onEncoderChange, CHANGE);
}

Attach both A and B to CHANGE interrupts for 4× resolution (4 counts per detent on a 20-step encoder).

Cockpit Integration Pattern

Encoder deltas are intent — not state. Send the delta to X-Plane and wait for the simulator dataref to confirm the new value before updating any local display. Never update the displayed value directly from encoder input alone.

// Radio tuning pattern — wrap heading 0-359, clamp frequency range

volatile int  encoderDelta = 0;   // ISR writes, loop() reads and resets
volatile bool buttonPress  = false;

// Call from main loop when you receive encoder events
void handleHeadingEncoder() {
    if (encoderDelta == 0) { return; }

    int delta = encoderDelta;
    encoderDelta = 0;

    int currentHeading = getSimDataref("sim/cockpit/autopilot/heading_mag");
    int newHeading = (currentHeading + delta + 360) % 360;

    // Send command to simulator — do NOT update local state directly.
    // State updates only after X-Plane confirms via dataref feedback.
    sendSimCommand("sim/autopilot/heading_up",   delta > 0 ? delta : 0);
    sendSimCommand("sim/autopilot/heading_down", delta < 0 ? -delta : 0);
}

void handleFrequencyEncoder(bool fine) {
    if (encoderDelta == 0) { return; }

    int delta = encoderDelta;
    encoderDelta = 0;

    // Fine: 25 kHz steps, coarse: 1 MHz steps
    float step = fine ? 0.025f : 1.0f;
    sendSimDataref("sim/cockpit/radios/com1_freq_hz",
                   getSimDataref("sim/cockpit/radios/com1_freq_hz") + delta * step * 1000);
}
Cockpit Applications
ApplicationRangeStep sizeNotes
Heading bug (HDG SEL)0–359°1° per detentWrap at 360°
COM / NAV radio (coarse)118–137 MHz1 MHz per detentOuter ring of a dual-concentric knob
COM / NAV radio (fine)channel spacing25 kHz per detentInner ring — same encoder channel, different GPIO
Altimeter baro (QNH)950–1050 hPa1 hPa per detentButton press toggles hPa / inHg
Course selector (CRS)0–359°1° per detentVOR / ILS course pointer on HSI
Speed selector (IAS / MACH)100–400 kt1 kt per detentButton press switches IAS ↔ MACH mode
Vertical speed (V/S)±6 000 fpm100 fpm per detentCentre detent at 0 — use encoder with tactile centre