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.
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.
| Type | Example | Steps / rev | Push button | Cockpit use |
|---|---|---|---|---|
| Incremental (mechanical) | KY-040 | 20 | Yes | All knobs — cheap, readily available |
| Incremental (mechanical) | EC11 | 20–30 | Yes | Panel-mount knobs with 6 mm flat shaft |
| Incremental (optical) | HEDL-5540 | 100–2000 | No | High-precision yoke / rudder position |
| Absolute (magnetic) | AS5048A | 16 384 (14-bit) | No | Throttle 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→1 | 0 | Clockwise (CW) |
| 1→0 | 1 | Clockwise (CW) |
| 0→1 | 1 | Counter-clockwise (CCW) |
| 1→0 | 0 | Counter-clockwise (CCW) |
INPUT_PULLUP — use any standard GPIO (0–33) instead. Avoid GPIO 0, 2, 12 (boot-strapping pins) unless you are certain they do not affect the boot sequence. | KY-040 pin | Connect to | Notes |
|---|---|---|
| CLK (A) | GPIO 32 | Primary quadrature channel |
| DT (B) | GPIO 33 | Secondary quadrature channel |
| SW | GPIO 25 | Push button — active LOW when pressed |
| + (VCC) | 3.3 V | KY-040 has on-board pull-ups |
| GND | GND |
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);
}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).
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);
}| Application | Range | Step size | Notes |
|---|---|---|---|
| Heading bug (HDG SEL) | 0–359° | 1° per detent | Wrap at 360° |
| COM / NAV radio (coarse) | 118–137 MHz | 1 MHz per detent | Outer ring of a dual-concentric knob |
| COM / NAV radio (fine) | channel spacing | 25 kHz per detent | Inner ring — same encoder channel, different GPIO |
| Altimeter baro (QNH) | 950–1050 hPa | 1 hPa per detent | Button press toggles hPa / inHg |
| Course selector (CRS) | 0–359° | 1° per detent | VOR / ILS course pointer on HSI |
| Speed selector (IAS / MACH) | 100–400 kt | 1 kt per detent | Button press switches IAS ↔ MACH mode |
| Vertical speed (V/S) | ±6 000 fpm | 100 fpm per detent | Centre detent at 0 — use encoder with tactile centre |