How to use a 1.3 inch display with a rotary encoder?

By admin

How to use a 1.3 inch display with a rotary encoder

You wire the 1.3 inch 240x240 ips display to your microcontroller via SPI, connect the rotary encoder to two GPIO pins plus a common ground, and then write code that reads encoder rotation and button presses to update the screen in real time. That’s the short answer. But if you want to actually build something that works reliably—like a menu system, a volume knob, or a parameter adjuster—you need to nail the hardware connections, handle encoder debouncing properly, and manage the display’s SPI bus without glitching the encoder reads. I’ve done this on both Arduino and ESP32 boards, and the devil is in the timing details.

Let’s start with the display. The 1.3 inch 240x240 ips display uses a 4-wire SPI interface: SCK (clock), MOSI (data), DC (data/command), and CS (chip select). You also need a RESET pin and a backlight pin (usually connected to 3.3V or PWM). The display driver is typically the ST7789, which runs at 240x240 pixels with 16-bit color. That means each frame requires 240 * 240 * 2 = 115,200 bytes. At an SPI clock of 40 MHz, a full screen refresh takes about 2.9 ms, but in practice with overhead, you’re looking at 10-15 ms per update. That’s fast enough for smooth animations, but you cannot keep redrawing the whole screen every time the encoder moves—you’ll miss encoder pulses.

The rotary encoder I use most often is the KY-040 module, which has three pins: CLK (output A), DT (output B), and SW (push button). Internally, it uses mechanical contacts with a 20-position detent per rotation. When you turn it, the two outputs produce quadrature signals with a 90-degree phase shift. The typical pulse rate is about 30 pulses per revolution, but cheap encoders can vary from 20 to 24. You need to read both pins on every state change, not just poll them, or you’ll lose counts. The switch pin is normally open and pulls low when pressed, but it bounces like crazy—expect 5-10 ms of chatter.

Here’s a typical wiring table for an Arduino Uno or Nano:

ComponentPinArduino Pin
Display SCKSPI Clock13 (SCK)
Display MOSISPI Data11 (MOSI)
Display DCData/Command9
Display CSChip Select10
Display RSTReset8
Display BLBacklight3.3V or PWM pin
Encoder CLKOutput A2 (interrupt)
Encoder DTOutput B3 (interrupt)
Encoder SWButton4 (input pullup)
Encoder GNDGroundGND
Encoder VCCPower5V (or 3.3V if module supports it)

On an ESP32, you have more flexibility. I assign the display to SPI2 (VSPI) with pins: SCK=18, MOSI=23, DC=2, CS=5, RST=4. The encoder CLK and DT go to pins 32 and 33, which support PCNT (pulse counter) hardware—this is a huge advantage because the ESP32 can count encoder pulses in the background without CPU intervention. The button goes to pin 34 (input only). The display backlight is on pin 25 with PWM at 5000 Hz, 8-bit resolution.

Now for the code. The biggest mistake I see is people polling the encoder in the main loop while also updating the display. SPI transactions block the CPU for milliseconds, and during that time, encoder pulses are lost. You must use interrupts for the encoder. On an AVR-based Arduino, attach an interrupt to both CLK and DT pins, and in the ISR, read the current state of both pins to determine direction. Here’s a minimal ISR pattern:

volatile int encoderPos = 0;
void encoderISR() {
static uint8_t lastState = 0;
uint8_t state = (digitalRead(encoderCLK) << 1) | digitalRead(encoderDT);
if (state != lastState) {
if ((lastState == 0b00 && state == 0b01) || (lastState == 0b11 && state == 0b10) ||
(lastState == 0b01 && state == 0b11) || (lastState == 0b10 && state == 0b00)) {
encoderPos++;
} else {
encoderPos--;
}
lastState = state;
}
}

This uses a state machine that works on every edge, not just one. It gives you 4 counts per detent, which you can divide by 4 in software. Test it with a serial print—if you get erratic counts, you need a hardware debounce RC filter (10k resistor + 100nF capacitor on each signal line to ground). I’ve found that cheap encoders without debounce circuits will produce false counts at high rotation speeds (above 50 RPM).

For the button, do not use interrupts. Instead, read it in the main loop with a debounce timer. A typical debounce period is 50 ms. Here’s a simple non-blocking debounce:

static unsigned long lastDebounceTime = 0;
static int lastButtonState = HIGH;
int reading = digitalRead(encoderSW);
if (reading != lastButtonState) {
lastDebounceTime = millis();
}
if ((millis() - lastDebounceTime) > 50) {
if (reading == LOW) {
// button pressed
}
}
lastButtonState = reading;

Now, the display update strategy. You cannot call display.fillScreen() or display.drawPixel() inside the encoder ISR—SPI is not interrupt-safe. Instead, set a volatile flag in the ISR (like “encoderChanged = true”), and in the main loop, check that flag and then update the display. To keep the UI responsive, only redraw the parts that changed. For a menu system, I use a double buffer approach: draw the entire screen to a 115,200-byte buffer in RAM (if you have it—ESP32 has 520 KB, but Arduino Uno only has 2 KB, so that’s not possible). On the Uno, I update only the text or number that changed, using display.fillRect() to clear the old value and display.drawChar() or display.print() to write the new one. That takes about 2-3 ms per small rectangle, which is fine for encoder updates at 10 Hz.

On an ESP32, you can use the TFT_eSPI library with the SPI DMA feature. This offloads the SPI transfer to hardware, so the CPU is free to read the encoder while the display is updating. To enable DMA, in the User_Setup.h file, set #define TFT_DMA 1 and #define SPI_FREQUENCY 40000000. Then use display.pushImageDMA() for fast updates. I measured a 50x50 pixel sprite update at 0.8 ms with DMA, compared to 3.2 ms without.

Here’s a practical example: a rotary encoder that adjusts a brightness value from 0 to 255, displayed as a number and a bar graph on the screen. The main loop reads encoder changes (from the flag), clamps the value, and calls a function that draws the bar graph using a filled rectangle and the number using drawNumber(). The bar graph is 200 pixels wide, so each unit changes the bar width by 200/255 ≈ 0.78 pixels. I round to the nearest integer, so the bar updates in steps of 1 pixel. This avoids flicker because I only redraw the bar when the width changes, not every loop iteration.

Power consumption matters too. The display draws about 40 mA with backlight at full brightness. The encoder draws negligible current (less than 1 mA). On a battery-powered project, you can reduce display power by turning off the backlight after 10 seconds of inactivity (use a timer), and only wake it on encoder rotation. The display’s sleep mode (via SPI command 0x10) drops current to 5 µA. But waking from sleep takes about 120 ms, so you need to handle that delay in the UI.

Timing conflicts are the most common failure mode. If you use the same SPI bus for other devices (like an SD card), you need to handle CS switching carefully. The encoder interrupts can fire during an SPI transaction, and if the ISR tries to access the display, you’ll get garbled data. Solution: disable interrupts around SPI transactions with cli() and sei() on AVR, or use portENTER_CRITICAL() on ESP32. But that increases interrupt latency. A better approach is to use a separate hardware timer to read the encoder at a fixed rate (e.g., 1 kHz) instead of edge interrupts. This is called “polling with timer” and works well if the encoder speed is below 500 pulses per second. On ESP32, you can use the LEDC timer to trigger an ADC or GPIO read, but I prefer the PCNT peripheral for zero CPU overhead.

If you need to display a menu with multiple items, the encoder scrolls through the list and the button selects. The screen shows 4 items at a time (each 60 pixels high on a 240-pixel display). When the encoder moves, you update the highlight bar (a filled rectangle with inverted colors) and the item text. I store the menu items in a PROGMEM array on Arduino to save RAM. The highlight position is calculated as (encoderPos % itemCount). To avoid flicker, I only redraw the two affected items (the one that lost highlight and the one that gained it).

For data logging, you can display encoder counts on the screen in real time. I’ve used this setup to measure the rotation of a motor shaft: the encoder gives 24 counts per revolution, and the display shows RPM calculated from the time between pulses. The formula is RPM = (60 * 1000) / (pulseInterval * 24). I update the display every 500 ms to avoid flicker. The accuracy is about ±2% at 100 RPM, limited by the encoder’s mechanical jitter.

Finally, test your system with a known reference. Use a function generator to simulate encoder pulses at 100 Hz and verify the display updates correctly. If you see missed counts or screen artifacts, check your wiring length (keep SPI lines under 10 cm), add ferrite beads on the encoder wires, and ensure the display’s VCC is clean (add a 10 µF capacitor near the display connector).