Understanding Rotary Encoders

What is a Rotary Encoder?
A rotary encoder is an electromechanical device that converts angular position or motion into digital signals. Unlike potentiometers (which are analog), encoders provide unlimited rotation and precise position tracking.

Types:
  • Incremental Encoders: Detect direction and amount of rotation (most common hobby type)
  • Absolute Encoders: Provide exact position at all times (typically expensive)
How It Works:
Incremental encoders have two output channels (A and B) that produce square wave signals 90° out of phase (quadrature signals). The phase relationship determines rotation direction.

Quadrature Signal Timing

Clockwise Rotation:

A: ¯¯|___|¯¯¯|___|¯¯¯|___|¯¯¯|

B: ___|¯¯¯|___|¯¯¯|___|¯¯¯|___

A leads B by 90°

Counter-Clockwise:

A: ___|¯¯¯|___|¯¯¯|___|¯¯¯|___

B: ¯¯|___|¯¯¯|___|¯¯¯|___|¯¯¯|

B leads A by 90°

State Transition Table

Previous A Previous B Current A Current B Direction
0001CW
0111CW
1110CW
1000CW
0010CCW
1011CCW
1101CCW
0100CCW

Demo 1: Volume Control Simulator

Circuit Diagram

Rotary Encoder (KY-040 or similar) CLK (A) ---[10kΩ pull-up]--- +5V | +---> Pin 2 (INT0) DT (B) ---[10kΩ pull-up]--- +5V | +---> Pin 3 (INT1) SW ---[10kΩ pull-up]--- +5V | +---> Pin 4 GND ---> GND + ---> +5V (some modules) Note: Many encoder modules have built-in pull-ups

Theory

Interrupt-Driven Design: Using hardware interrupts ensures no rotation pulses are missed, even when the main loop is busy. Arduino Uno has two external interrupts:
  • INT0 on Pin 2
  • INT1 on Pin 3
Debouncing: Mechanical encoders can produce contact bounce, creating false readings. Software debouncing filters these out by requiring a minimum time between valid transitions.

Calculations

Resolution:
Typical hobby encoders: 20 pulses per revolution (PPR)
With quadrature decoding: 20 × 4 = 80 counts per revolution

Angular Resolution:
360° / 80 counts = 4.5° per count

Debounce Time:
Typical mechanical bounce: 5-50ms
Software filter: Ignore transitions < 1ms apart
Minimum time = 1000 microseconds

Arduino Code

// Rotary Encoder Volume Control
const int PIN_A = 2; // CLK
const int PIN_B = 3; // DT
const int PIN_SW = 4; // Switch (button)

volatile int encoderPos = 50; // Start at 50% volume
volatile int lastEncoded = 0;
volatile unsigned long lastInterrupt = 0;

int volume = 50; // 0-100%
bool muted = false;

void setup() {
pinMode(PIN_A, INPUT_PULLUP);
pinMode(PIN_B, INPUT_PULLUP);
pinMode(PIN_SW, INPUT_PULLUP);

// Attach interrupts
attachInterrupt(digitalPinToInterrupt(PIN_A), updateEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(PIN_B), updateEncoder, CHANGE);

Serial.begin(9600);
Serial.println("Volume Control Ready");
printVolume();
}

void loop() {
// Update volume from encoder position
static int lastPos = encoderPos;
if(encoderPos != lastPos) {
volume = constrain(encoderPos, 0, 100);
lastPos = encoderPos;
printVolume();
}

// Check for button press (mute/unmute)
static bool lastButton = HIGH;
bool currentButton = digitalRead(PIN_SW);

if(lastButton == HIGH && currentButton == LOW) {
delay(50); // Debounce
if(digitalRead(PIN_SW) == LOW) {
muted = !muted;
Serial.print("MUTED: ");
Serial.println(muted ? "ON" : "OFF");
}
}
lastButton = currentButton;

delay(10);
}

void updateEncoder() {
// Debouncing
unsigned long interruptTime = micros();
if(interruptTime - lastInterrupt < 1000) return;
lastInterrupt = interruptTime;

// Read current state
int MSB = digitalRead(PIN_A);
int LSB = digitalRead(PIN_B);
int encoded = (MSB < 1) | LSB;
int sum = (lastEncoded < 2) | encoded;

// Determine direction
if(sum == 0b1101 || sum == 0b0100 || sum == 0b0010 || sum == 0b1011) {
encoderPos++;
}
else if(sum == 0b1110 || sum == 0b0111 || sum == 0b0001 || sum == 0b1000) {
encoderPos--;
}

lastEncoded = encoded;
}

void printVolume() {
Serial.print("Volume: ");
Serial.print(volume);
Serial.print("% ");

// Visual bar
Serial.print("[");
for(int i = 0; i < 20; i++) {
if(i < volume / 5) Serial.print("=");
else Serial.print(" ");
}
Serial.println("]");
}

Use Cases

Demo 2: Digital Position Counter with Direction Display

Circuit Diagram

Same as Demo 1, plus 3 LEDs: Pin 9 ---[220Ω]---LED(Green)---|>|---GND (CW indicator) Pin 10 ---[220Ω]---LED(Red)-----|>|---GND (CCW indicator) Pin 11 ---[220Ω]---LED(Blue)----|>|---GND (Position zero)

Theory

Absolute Position Tracking: By counting all encoder transitions, we can track absolute position from a known starting point (zero reference).

Applications in Motion Control:
  • CNC Machines: Track tool position
  • Robotics: Joint angle tracking
  • Automated Systems: Linear actuator positioning
Encoder Resolution Enhancement: Quadrature decoding provides 4× resolution increase by detecting both rising and falling edges of both channels.

Calculations

Position Calculation:
If encoder has 20 PPR and we want to track degrees:

Counts per revolution = 20 × 4 = 80
Degrees per count = 360° / 80 = 4.5°

Position (degrees) = count × 4.5°

Linear Motion Example:
If encoder drives a lead screw with 2mm pitch:
Linear distance = (count / 80) × 2mm = count × 0.025mm

Arduino Code

// Position Counter with Direction Display
const int PIN_A = 2;
const int PIN_B = 3;
const int PIN_SW = 4;
const int LED_CW = 9; // Green - Clockwise
const int LED_CCW = 10; // Red - Counter-clockwise
const int LED_ZERO = 11; // Blue - At zero position

volatile long encoderCount = 0;
volatile int lastEncoded = 0;
volatile unsigned long lastInterrupt = 0;
volatile int lastDirection = 0; // 1=CW, -1=CCW, 0=stopped

void setup() {
pinMode(PIN_A, INPUT_PULLUP);
pinMode(PIN_B, INPUT_PULLUP);
pinMode(PIN_SW, INPUT_PULLUP);
pinMode(LED_CW, OUTPUT);
pinMode(LED_CCW, OUTPUT);
pinMode(LED_ZERO, OUTPUT);

attachInterrupt(digitalPinToInterrupt(PIN_A), updateEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(PIN_B), updateEncoder, CHANGE);

Serial.begin(9600);
Serial.println("Position Counter Ready");
Serial.println("Press button to reset position");
}

void loop() {
// Update display
static long lastCount = -999;
if(encoderCount != lastCount) {
displayPosition();
lastCount = encoderCount;
}

// Update direction LEDs
if(lastDirection > 0) {
digitalWrite(LED_CW, HIGH);
digitalWrite(LED_CCW, LOW);
} else if(lastDirection < 0) {
digitalWrite(LED_CW, LOW);
digitalWrite(LED_CCW, HIGH);
} else {
digitalWrite(LED_CW, LOW);
digitalWrite(LED_CCW, LOW);
}

// Zero indicator
digitalWrite(LED_ZERO, encoderCount == 0 ? HIGH : LOW);

// Reset button
if(digitalRead(PIN_SW) == LOW) {
delay(50);
if(digitalRead(PIN_SW) == LOW) {
encoderCount = 0;
Serial.println("\n*** POSITION RESET TO ZERO ***\n");
delay(200);
}
}

delay(100);
}

void updateEncoder() {
unsigned long interruptTime = micros();
if(interruptTime - lastInterrupt < 1000) return;
lastInterrupt = interruptTime;

int MSB = digitalRead(PIN_A);
int LSB = digitalRead(PIN_B);
int encoded = (MSB < 1) | LSB;
int sum = (lastEncoded < 2) | encoded;

if(sum == 0b1101 || sum == 0b0100 || sum == 0b0010 || sum == 0b1011) {
encoderCount++;
lastDirection = 1;
}
else if(sum == 0b1110 || sum == 0b0111 || sum == 0b0001 || sum == 0b1000) {
encoderCount--;
lastDirection = -1;
}

lastEncoded = encoded;
}

void displayPosition() {
// Calculate degrees (assuming 20 PPR encoder)
float degrees = (encoderCount % 80) * 4.5;
int revolutions = encoderCount / 80;

Serial.print("Count: ");
Serial.print(encoderCount);
Serial.print(" | Rev: ");
Serial.print(revolutions);
Serial.print(" | Angle: ");
Serial.print(degrees, 1);
Serial.print("° | Dir: ");
Serial.println(lastDirection > 0 ? "CW" : (lastDirection < 0 ? "CCW" : "STOP"));
}

Use Cases