The CrowPanel 1.28-inch HMI ESP32 Rotary Display by Elecrow is a compact, round module that integrates an ESP32-S3 microcontroller, a 1.28-inch 240×240 IPS circular touchscreen, an RGB LED ring, and a rotary encoder with a push button in a single unit.
In this tutorial, we’ll go through the essential steps to get started with the CrowPanel; from the initial setup to testing its display and input capabilities. You’ll learn how to control the RGB LEDs, read rotary encoder data for smooth user input and capture touch events from the circular display using simple code examples.
Required Parts
You will only need a CrowPanel 1.28-inch HMI ESP32 Rotary Display board. The display module comes with a USB cable and GPIO connector, so no further hardware is needed.

CrowPanel 1.28inch-HMI ESP32 Rotary Display
Makerguides is a participant in affiliate advertising programs designed to provide a means for sites to earn advertising fees by linking to Amazon, AliExpress, Elecrow, and other sites. As an Affiliate we may earn from qualifying purchases.
Hardware of the CrowPanel 1.28inch-HMI ESP32 Rotary Display
The CrowPanel 1.28-inch HMI ESP32 Rotary Display is an integrated hardware module that combines a high-resolution round IPS display, a capacitive touchscreen, a ring of RGB LEDs and a rotary encoder with push-button functionality, all driven by an ESP32-S3 microcontroller.
The module is designed to serve as a compact human–machine interface (HMI) for embedded and IoT projects where visual feedback and interactive control are required in a small footprint. The picture below shows the front and side of the module with its dimensions:

Microcontroller
At its core, the CrowPanel uses the ESP32-S3R8, a dual-core Xtensa LX7 processor running at up to 240 MHz. The microcontroller integrates Wi-Fi (802.11 b/g/n) and Bluetooth 5.0 LE, providing both local and remote connectivity options. It includes 8 MB of PSRAM and 16 MB of flash memory.
Display
The display is a 1.28-inch circular IPS panel with a resolution of 240 × 240 pixels and full 65 K-color support. The screen uses SPI communication for data transfer and provides a wide viewing angle of up to 178 degrees. A capacitive touch controller is integrated on the same module, connected via the I²C interface, allowing precise multi-point touch detection and gesture support.
For physical input, the module includes a rotary encoder that features a built-in push button. The encoder provides incremental position feedback suitable for menu navigation, parameter adjustment, or rotary selection controls.
RGB LED Strip
The module also includes an integrated WS2812 RGB LED strip consisting of five individually addressable LEDs. The LEDs are connected in series and can be driven using common libraries such as Adafruit NeoPixel or FastLED.
Each WS2812 LED operates on a 5 V supply and typically draws up to 60 mA at maximum brightness when all color channels (red, green, and blue) are active at full intensity. Therefore, when all five LEDs are illuminated at maximum brightness, the total current consumption can reach approximately 300 mA (5 × 60 mA).
Interfaces
The board is powered through a 5 V USB-C connector and includes an onboard 3.3 V voltage regulator for stable operation of the ESP32 and peripheral components. Additional interfaces such as UART, I²C, SPI, FPC and GPIO headers are available for external expansion, allowing developers to connect sensors, actuators, or other control devices.
The module also includes a reset button, a boot button for firmware flashing, and a status LED for visual feedback during operation. The picture below shows the back of the module with the buttons and various interfaces:

Pins
The following table shows which GPIO pins of the ESP32-S3 are connected to which components of the module. You will need this information if you want to write code for the module.
| Component | Interface / Function | Pin Assignments |
|---|---|---|
| Display (GC9A01) | SPI | SCLK = 10, MOSI = 11, MISO = -1, DC = 3, CS = 9, RST = 14 |
| Screen Backlight | GPIO | SCREEN_BACKLIGHT_PIN = 46 |
| Touchscreen (CST816D) | I²C | SDA = 6, SCL = 7, INT = 5, RST = 13 |
| OLED (SSD1306) | I²C | SDA = 38, SCL = 39 |
| RGB LED (WS2812) | Digital Output | LED_PIN = 48, LED_NUM = 5 |
| Rotary Encoder | GPIO | ENCODER_A_PIN = 45, ENCODER_B_PIN = 42, SWITCH_PIN = 41 |
| Power Indicator | GPIO | POWER_LIGHT_PIN = 40 |
| Test I/O | GPIO | IO4, IO12 |
Technical Specification
Finally, the technical specification of the Crowpanel display module is summarized in the table below.
| Specification | Details |
|---|---|
| Microcontroller | ESP32-S3R8 dual-core Xtensa LX7 @ 240 MHz |
| Memory | 8 MB PSRAM, 16 MB Flash |
| Display Type | 1.28-inch round IPS TFT |
| Resolution | 240 × 240 pixels |
| Color Depth | 65 K colors |
| Viewing Angle | 178 degrees |
| Touch Interface | Capacitive, I²C communication |
| Rotary Input | Incremental encoder with push button |
| Communication Interfaces | SPI, I²C, UART, GPIO |
| Connectivity | Wi-Fi 802.11 b/g/n, Bluetooth 5.0 LE |
| Power Supply | 5 V via USB-C (onboard 3.3 V regulator) |
| Programming Interface | USB-C (supports Arduino, ESP-IDF) |
| Additional Features | Reset and Boot buttons, RGB LED Strip |
Installation of ESP32 Core
If this is your first project with a board of the ESP32 series, you will need to install the ESP32 core first. If ESP32 boards are already installed in your Arduino IDE, you can skip this section.
Start by opening the Preferences dialog by selecting “Preferences…” from the “File” menu. This will open the Preferences dialog shown below.
Under the Settings tab you will find an edit box at the bottom of the dialog that is labelled “Additional boards manager URLs“:

In this input field copy the following URL:
https://espressif.github.io/arduino-esp32/package_esp32_dev_index.json
This will let the Arduino IDE know, where to find the ESP32 core libraries. Next we will install the ESP32 boards using the Boards Manager.
Open the Boards Manager via “Tools -> Boards -> Board Manager”. You will see the Boards Manager appearing in the left Sidebar. Enter “ESP32” in the search field at the top and you should see two types of ESP32 boards; the “Arduino ESP32 Boards” and the “esp32 by Espressif” boards. We want the “esp32 libraries by Espressif”. Click on the INSTALL button and wait until the download and install is complete.

Selecting Board
Finally we need to select a ESP32 board. In case of the CrowPanel 1.28inch-HMI ESP32 Rotary Display, we pick the generic “ESP32S3 Dev Module”. For that, click on the drop-down menu and then on “Select other board and port…”:

This will open a dialog where you can enter “esp32s3 dev” in the search bar. You will see the “ESP32S3 Dev Module” board under Boards. Click on it and the COM port to activate it and then click OK:

Note that you need to connect the board via the USB cable to your computer, before you can select a COM port. The CrowPanel Display Module has two ports, a USB port and a UART port. You can use both to program the board but for the UART port you will need USB-to-TTL converter, see below:

The easier way is to connect the USB cable that comes with CrowPanel Display Module directly to the port labelled “USB” as shown below. In this case you do not need a USB-to-TTL converter.

Tool Settings
Below are the settings you should use with the board. You find them under the Tools menu in your Arduino IDE.

Most importantly for the code examples in the following sections is to set USB CDC on Boot to “Enabled” and also set USB mode to “Hardware CDC and JTAG”. This allows you to send or receive data via the serial port, which you will need for debugging.
Code Example: Serial Interface
We start by testing the Serial communication. Open your Arduino IDE, enter the following code and upload the code to the CrowPanel Display Module.
void setup() {
Serial.begin(115200);
}
void loop() {
Serial.println("Makerguides");
delay(2000);
}
Then open the Serial Monitor and you should see the text “Makerguides” printed every two seconds. If not, make sure that your Tools settings and Baud rate (115200) settings are correct.
Code Example: RGB LED strip
Next we try the on-board RGB LED. Howver, you will need to install the Adafruit_NeoPixel library first. Open the LIBRARY MANAGER in you Arduino IDE, type “Adafruit NeoPixel” in the search bar and install the Adafruit NeoPixel library by Adafruit:

The CrowPanel Display Module has an integrated WS2812 RGB LED strip with five LEDs. The following example demonstrates how to initialize the LED strip, set a color, and gradually increase its brightness using the Adafruit NeoPixel library.
// www.makerguides.com
// ESP32 core: 3.3.2
// Adafruit_NeoPixel: 1.15.2
#include <Adafruit_NeoPixel.h>
#define LED_PIN 48
#define LED_NUM 5
Adafruit_NeoPixel strip = Adafruit_NeoPixel(LED_NUM, LED_PIN, NEO_GRB + NEO_KHZ800);
void updateBrightness(int brightness) {
strip.setBrightness(brightness);
strip.show();
delay(100);
}
void initLEDs(uint8_t r, uint8_t g, uint8_t b) {
strip.begin();
strip.setBrightness(100);
for (int i = 0; i < LED_NUM; i++) {
strip.setPixelColor(i, strip.Color(r, g, b));
}
strip.show();
}
void setup() {
Serial.begin(115200);
initLEDs(255, 255, 50); // Yellow
}
void loop() {
for(int brightness=5; brightness<255; brightness+=5) {
updateBrightness(brightness);
Serial.printf("Brightness: %d\n", brightness);
}
}
Imports
The program begins by including the Adafruit_NeoPixel library, which provides a simple interface for controlling WS2812 LEDs. This library handles the strict timing required by the LEDs’ single-wire communication protocol, allowing you to focus on color and animation logic rather than low-level timing details.
#include <Adafruit_NeoPixel.h>
Constants
Two constants are defined to specify how many LEDs are connected and which GPIO pin is used to control them. The CrowPanel connects the WS2812 strip to GPIO 48, and the strip contains five LEDs.
#define LED_PIN 48 #define LED_NUM 5
Object Creation
Next, a NeoPixel strip object is created. The constructor takes three parameters: the number of LEDs, the control pin, and the pixel type configuration. The flag NEO_GRB + NEO_KHZ800 specifies that each LED uses GRB color order (green, red, blue) and operates at 800 kHz, which is the standard frequency for WS2812 LEDs.
Adafruit_NeoPixel strip = Adafruit_NeoPixel(LED_NUM, LED_PIN, NEO_GRB + NEO_KHZ800);
This object will be used throughout the program to control the LEDs — setting colors, adjusting brightness, and sending data updates.
The updateBrightness() Function
This helper function adjusts the brightness of the LED strip dynamically. The setBrightness() method accepts a value between 0 (off) and 255 (maximum brightness). After setting the brightness, the show() function must be called to send the updated data to the LEDs.
void updateBrightness(int brightness) {
strip.setBrightness(brightness);
strip.show();
delay(100);
}
The short delay ensures a smooth, visible transition between brightness levels, creating a gradual fade-in effect.
The initLEDs() Function
Before you can control the LEDs, they must be initialized. The begin() function prepares the data pin for communication, and setBrightness(100) sets an initial brightness level to avoid starting at full intensity.
The for-loop then iterates over each of the five LEDs, assigning them a color using setPixelColor(). The strip.Color(r, g, b) method converts the individual red, green, and blue values into the proper 24-bit color format required by the LEDs.
void initLEDs(uint8_t r, uint8_t g, uint8_t b) {
strip.begin();
strip.setBrightness(100);
for (int i = 0; i < LED_NUM; i++) {
strip.setPixelColor(i, strip.Color(r, g, b));
}
strip.show();
}
Finally, show() transmits the color data to all LEDs, illuminating them simultaneously in the selected color. In our case that will be yellow.
Setup
The setup() function runs once when the ESP32 is powered on or reset. It begins by initializing serial communication at 115200 baud, allowing messages to be printed to the serial monitor. It then calls initLEDs(255, 255, 50), which lights up all five LEDs in a soft yellow tone.
void setup() {
Serial.begin(115200);
initLEDs(255, 255, 50); // Yellow
}
Loop
The loop() function runs continuously after setup completes. In this example, it gradually increases the brightness from 5 to 255 in steps of 5, calling updateBrightness() each time. After each adjustment, it prints the current brightness value to the serial monitor.
void loop() {
for(int brightness=5; brightness<255; brightness+=5) {
updateBrightness(brightness);
Serial.printf("Brightness: %d\n", brightness);
}
}
This creates a smooth fade-in animation that cycles repeatedly. Make sure not to set brightness to zero. The LED strip will stay dark, even if you change the brightness later on. You would have to set LED colors for the strip again.
Code Example: Rotary Encoder
This sketch builds on the NeoPixel groundwork from the previous example and adds real-time brightness control using a mechanical rotary encoder. The LEDs still use the Adafruit NeoPixel library for timing and color handling, while the encoder is read via an interrupt service routine for responsive, jitter-free interaction. The result is a smooth, hardware-driven brightness knob for the CrowPanel’s five-LED WS2812 strip.
// www.makerguides.com
// ESP32 core: 3.3.2
// Adafruit_NeoPixel: 1.15.2
#include <Adafruit_NeoPixel.h>
#define LED_PIN 48
#define LED_NUM 5
#define ENCODER_CLK 45 // A
#define ENCODER_DT 42 // B
Adafruit_NeoPixel strip = Adafruit_NeoPixel(LED_NUM, LED_PIN, NEO_GRB + NEO_KHZ800);
int brightness = 50; // 0..255
int enc_state = 0;
int old_state = -1;
bool has_changed = true;
void IRAM_ATTR encoder_irq() {
enc_state = digitalRead(ENCODER_CLK);
if (enc_state != old_state) {
brightness += (digitalRead(ENCODER_DT) == enc_state) ? -5 : +5;
brightness = constrain(brightness, 5, 255);
old_state = enc_state;
has_changed = true;
}
}
void updateBrightness() {
strip.setBrightness(brightness);
strip.show();
delay(200);
}
void initLEDs(uint8_t r, uint8_t g, uint8_t b) {
strip.begin();
for (int i = 0; i < LED_NUM; i++) {
strip.setPixelColor(i, strip.Color(r, g, b));
}
updateBrightness();
}
void initEncoder() {
pinMode(ENCODER_CLK, INPUT_PULLUP);
pinMode(ENCODER_DT, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), encoder_irq, CHANGE);
}
void setup() {
Serial.begin(115200);
initEncoder();
initLEDs(255, 255, 50);
has_changed = true;
}
void loop() {
if (has_changed) {
has_changed = false;
updateBrightness();
Serial.printf("Brightness: %d\n", brightness);
}
delay(1);
}
Imports
The program reuses the same NeoPixel library used earlier, so the timing-critical signaling for WS2812 LEDs is fully managed and you can focus on input logic and animation.
#include <Adafruit_NeoPixel.h>
Constants
The LED strip remains connected to GPIO 48 and contains five pixels as before. Two additional constants define the rotary encoder’s A and B channels on GPIO 45 and GPIO 42.
#define LED_PIN 48 #define LED_NUM 5 #define ENCODER_CLK 45 // A #define ENCODER_DT 42 // B
Objects
The NeoPixel object is configured with the same parameters explained in the earlier example. The GRB color order and 800 kHz signaling match WS2812 requirements.
Adafruit_NeoPixel strip = Adafruit_NeoPixel(LED_NUM, LED_PIN, NEO_GRB + NEO_KHZ800);
State Variables
The sketch maintains a global brightness value in the 0 to 255 range and tracks the encoder’s instantaneous and previous states. A boolean flag signals the main loop that a brightness update, which causes the brightness to be adjusted in the main loop.
int brightness = 50; // 0..255 int enc_state = 0; int old_state = -1; bool has_changed = true;
The brightness variable is intentionally initialized to a moderate level so the LEDs do not power up at maximum load. The encoder states are seeded so that the first edge will be detected properly, and has_changed starts true to force an initial render.
Interrupt Service Routine: encoder_irq
The encoder is decoded in an interrupt context to keep UI latency low. The service routine runs on both edges of the CLK channel and compares the DT channel to determine rotation direction. If DT equals CLK, the code treats the motion as one direction and subtracts five brightness steps; if not equal, it adds five. The step size of five is a practical compromise between granular control and responsiveness.
void IRAM_ATTR encoder_irq() {
enc_state = digitalRead(ENCODER_CLK);
if (enc_state != old_state) {
brightness += (digitalRead(ENCODER_DT) == enc_state) ? -5 : +5;
brightness = constrain(brightness, 5, 255);
old_state = enc_state;
has_changed = true;
}
}
The IRAM_ATTR attribute ensures the routine is placed in instruction RAM, which avoids flash wait states on the ESP32 during interrupts. The constrain call enforces a minimum of 5 to avoid completely dark output and a maximum of 255 to cap brightness. The flag has_changed defers the actual LED update to the main thread, keeping the ISR short and deterministic.
Brightness Update: updateBrightness
Actual LED updates are centralized here. The function applies the current brightness to the strip and calls show to transmit the buffered pixel data. A short delay is used to make successive changes visible and to avoid saturating the LED update rate.
void updateBrightness() {
strip.setBrightness(brightness);
strip.show();
delay(200);
}
Because brightness on NeoPixel is a global multiplier applied at transmit time, this approach updates intensity without re-writing individual pixel colors.
LED Initialization: initLEDs
The initialization logic mirrors the earlier example but defers brightness to updateBrightness so the same code path is used both at startup and during knob turns. All five pixels are assigned the same initial color, which you can change for status feedback.
void initLEDs(uint8_t r, uint8_t g, uint8_t b) {
strip.begin();
for (int i = 0; i < LED_NUM; i++) {
strip.setPixelColor(i, strip.Color(r, g, b));
}
updateBrightness();
}
Calling updateBrightness at the end guarantees that the initial color appears at the current global brightness level.
Encoder Initialization: initEncoder
The encoder channels are configured as inputs with internal pull-ups because many panel encoders are open-collector or mechanical switch types. The interrupt is attached to the CLK line on both edges so each detent produces a state transition that the ISR can decode.
void initEncoder() {
pinMode(ENCODER_CLK, INPUT_PULLUP);
pinMode(ENCODER_DT, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), encoder_irq, CHANGE);
}
Using interrupts on the CLK line, rather than polling in the loop, removes missed steps at higher rotation speeds and reduces CPU usage. If your encoder is particularly noisy, you can add simple debouncing by ignoring changes within a microsecond window or by sampling both channels again after a short delay.
Setup
Serial output is initialized for visibility, the encoder is armed first so user input is captured immediately, and the LEDs are brought up with a yellow hue. The change flag is set to ensure the first frame is pushed to the strip even if the encoder has not moved yet.
void setup() {
Serial.begin(115200);
initEncoder();
initLEDs(255, 255, 50);
has_changed = true;
}
Loop
The main loop is deliberately minimal. It checks whether the ISR has flagged a brightness change and, if so, applies the new level and reports it over serial.
void loop() {
if (has_changed) {
has_changed = false;
updateBrightness();
Serial.printf("Brightness: %d\n", brightness);
}
delay(1);
}
This structure keeps time-critical, short work in the interrupt and leaves the heavier LED I/O in the foreground, which is a robust pattern for responsive UI code on the ESP32.
Code Example: Display
In this next example we will control the brightness of the display using the rotary encoder. The picture below shows how three different brightness values (10%, 50%, 100%) for the display:

First, you will need to install the Arduino_GFX library by moononournation to be able to control the display. Open the LIBRARY MANAGER, type “GFX Library for Arduino” into the search bar, find the “GFX Library for Arduino” by Moon, and click the INSTALL button:

The following sketch connects the rotary encoder to the GC9A01 TFT display using the Arduino_GFX library and adds PWM backlight control. It extends the encoder concepts from the previous examples and will allow you to control the brightness of the display with a push-button toggle for the backlight.
// www.makerguides.com
// ESP32 core: 3.3.2
// Arduino_GFX_Library: 1.6.2
#include <Arduino_GFX_Library.h>
#define TFT_SCLK 10
#define TFT_MOSI 11
#define TFT_DC 3
#define TFT_CS 9
#define TFT_RES 14
#define TFT_BLK 46
#define LCD_PWR_EN1 1 // LCD_3V3 rail
#define LCD_PWR_EN2 2 // LEDA_3V3 rail
#define ENCODER_CLK 45 // A
#define ENCODER_DT 42 // B
#define ENCODER_BTN 41 // SW (active-LOW)
int brightness = 50; // 0..100 %
bool btn_display = true;
int enc_state = 0;
int old_state = -1;
bool has_changed = true;
Arduino_ESP32SPI *bus = new Arduino_ESP32SPI(
TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, GFX_NOT_DEFINED, FSPI, true
);
Arduino_GFX *gfx = new Arduino_GC9A01(bus, TFT_RES, 0 /* rotation */, true /* IPS */);
void IRAM_ATTR encoder_irq() {
enc_state = digitalRead(ENCODER_CLK);
if (enc_state != old_state) {
brightness += (digitalRead(ENCODER_DT) == enc_state) ? 1 : -1;
brightness = constrain(brightness, 0, 100);
old_state = enc_state;
has_changed = true;
}
}
void IRAM_ATTR button_irq() {
if (!digitalRead(ENCODER_BTN)) {
btn_display = !btn_display;
has_changed = true;
}
}
void drawText() {
gfx->setTextSize(2);
gfx->setCursor(60, 55);
gfx->println(F("Makerguides"));
}
void updateBrightness() {
gfx->fillRect(65, 95, 120, 65, WHITE);
gfx->setTextSize(6);
gfx->setCursor(65, 100);
gfx->printf("%3d", brightness);
int duty = map(brightness, 0, 100, 0, 255);
analogWrite(TFT_BLK, btn_display ? duty : 0);
}
void initPower() {
pinMode(LCD_PWR_EN1, OUTPUT);
pinMode(LCD_PWR_EN2, OUTPUT);
digitalWrite(LCD_PWR_EN1, HIGH); // must be HIGH
digitalWrite(LCD_PWR_EN2, HIGH); // must be HIGH
pinMode(TFT_BLK, OUTPUT);
analogWrite(TFT_BLK, 50);
}
void initDisplay() {
delay(20);
gfx->begin();
gfx->fillScreen(WHITE);
gfx->setTextColor(BLACK);
}
void initEncoder() {
pinMode(ENCODER_BTN, INPUT_PULLUP);
pinMode(ENCODER_CLK, INPUT_PULLUP);
pinMode(ENCODER_DT, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), encoder_irq, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENCODER_BTN), button_irq, CHANGE);
}
void setup() {
Serial.begin(115200);
initPower();
initEncoder();
initDisplay();
drawText();
has_changed = true;
}
void loop() {
if (has_changed) {
has_changed = false;
updateBrightness();
Serial.printf("Brightness: %d%% (display %s)\n",
brightness, btn_display ? "ON" : "OFF");
}
delay(1);
}
Imports
The program relies on Arduino_GFX for fast SPI drawing primitives and a GC9A01 panel driver. The library hides controller-specific commands and provides a consistent API for initialization, color fills, and text rendering.
#include <Arduino_GFX_Library.h>
Constants
Pins define the SPI connection to the round TFT, power-enable rails for the panel and its LED anode, the PWM-driven backlight pin, and the rotary encoder’s channels and switch. Brightness is expressed as a percentage so user feedback can be printed directly as 0–100%.
#define TFT_SCLK 10 #define TFT_MOSI 11 #define TFT_DC 3 #define TFT_CS 9 #define TFT_RES 14 #define TFT_BLK 46 #define LCD_PWR_EN1 1 // LCD_3V3 rail #define LCD_PWR_EN2 2 // LEDA_3V3 rail #define ENCODER_CLK 45 // A #define ENCODER_DT 42 // B #define ENCODER_BTN 41 // SW (active-LOW)
State Variables
Runtime state tracks the current brightness percentage, whether the backlight is enabled, the instantaneous encoder state, and a change flag that lets the ISR schedule work for the main thread. As before, keeping ISRs short and deferring I/O to the foreground avoids timing hiccups.
int brightness = 50; // 0..100 % bool btn_display = true; int enc_state = 0; int old_state = -1; bool has_changed = true;
Display Bus and Driver Objects
The Arduino_GFX stack is split into a bus object and a panel object. The bus wraps ESP32’s FSPI host with DC, CS, SCLK, and MOSI. The panel object targets the GC9A01 controller and knows how to initialize a round, IPS panel. Rotation is set to zero and the last boolean marks the panel as IPS to select the correct color inversion and gamma.
Arduino_ESP32SPI *bus = new Arduino_ESP32SPI( TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, GFX_NOT_DEFINED, FSPI, true ); Arduino_GFX *gfx = new Arduino_GC9A01(bus, TFT_RES, 0 /* rotation */, true /* IPS */);
Encoder Interrupt Service Routine
The encoder ISR mirrors the logic from earlier, but the step size is one percent per edge to give fine control. The routine samples the CLK channel to detect an edge, compares DT to determine direction, updates the percentage, constrains the result, and flips the change flag.
void IRAM_ATTR encoder_irq() {
enc_state = digitalRead(ENCODER_CLK);
if (enc_state != old_state) {
brightness += (digitalRead(ENCODER_DT) == enc_state) ? 1 : -1;
brightness = constrain(brightness, 0, 100);
old_state = enc_state;
has_changed = true;
}
}
Button Interrupt Service Routine
The encoder’s push button is active-low. The ISR reads the pin and toggles a boolean that represents the backlight state. The draw and PWM update are deferred to the main loop by setting the change flag.
void IRAM_ATTR button_irq() {
if (!digitalRead(ENCODER_BTN)) {
btn_display = !btn_display;
has_changed = true;
}
}
Drawing Static Text
A small helper renders a site label once at startup. Text size and cursor are positioned for a round 240×240 display. Note that Arduino_GFX uses a pixel-based cursor that advances with prints.
void drawText() {
gfx->setTextSize(2);
gfx->setCursor(60, 55);
gfx->println(F("Makerguides"));
}
Updating Brightness and Backlight
This function refreshes the on-screen numeric value and applies PWM to the backlight pin. The rectangular area behind the digits is cleared to white to avoid ghosting as the numbers change. The percentage is mapped to an 8-bit duty cycle, which analogWrite translates into a hardware PWM via ESP32’s LEDC. When the display toggle is off, the duty is forced to zero.
void updateBrightness() {
gfx->fillRect(65, 95, 120, 65, WHITE);
gfx->setTextSize(6);
gfx->setCursor(65, 100);
gfx->printf("%3d", brightness);
int duty = map(brightness, 0, 100, 0, 255);
analogWrite(TFT_BLK, btn_display ? duty : 0);
}
Power Initialization
The panel’s logic and LED anode rails are enabled before any display commands are sent. Both enables are driven high. The backlight pin is configured for PWM and set to low brightness (50), which makes the text visible but not too bright.
void initPower() {
pinMode(LCD_PWR_EN1, OUTPUT);
pinMode(LCD_PWR_EN2, OUTPUT);
digitalWrite(LCD_PWR_EN1, HIGH); // must be HIGH
digitalWrite(LCD_PWR_EN2, HIGH); // must be HIGH
pinMode(TFT_BLK, OUTPUT);
analogWrite(TFT_BLK, 50);
}
Display Initialization
After a short settle delay for the power rails, the GC9A01 driver is started, the screen is cleared to white, and the text color is set to black. From here the sketch can draw primitives and text at will.
void initDisplay() {
delay(20);
gfx->begin();
gfx->fillScreen(WHITE);
gfx->setTextColor(BLACK);
}
Encoder Initialization
The encoder channels and button use internal pull-ups, which match typical open-contact encoders. Interrupts are attached to the CLK channel for quadrature decoding and to the button for toggling. Using CHANGE captures both press and release edges; the ISR itself checks for the active-low condition.
void initEncoder() {
pinMode(ENCODER_BTN, INPUT_PULLUP);
pinMode(ENCODER_CLK, INPUT_PULLUP);
pinMode(ENCODER_DT, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENCODER_CLK), encoder_irq, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENCODER_BTN), button_irq, CHANGE);
}
Setup
The system powers the panel, arms the encoder interrupts, initializes the display, renders the static title, and primes the change flag so the first brightness frame and PWM update happen immediately. Serial logging is enabled for visibility during testing.
void setup() {
Serial.begin(115200);
initPower();
initEncoder();
initDisplay();
drawText();
has_changed = true;
}
Loop
The foreground checks whether the ISRs have requested an update. When set, it redraws the numeric brightness, sets the backlight brighness, and prints a status line showing the percentage and whether the backlight is currently enabled.
void loop() {
if (has_changed) {
has_changed = false;
updateBrightness();
Serial.printf("Brightness: %d%% (display %s)\n",
brightness, btn_display ? "ON" : "OFF");
}
delay(1);
}
Code Example: Touch screen
This last example demonstrates how to read touch data from the CST816D capacitive touch controller integrated into the CrowPanel 1.28-inch HMI ESP32 Rotary Display. It draws red dots wherever the user touches the screen. See the example photo below:

The code following reuses the same Arduino_GFX display pipeline introduced earlier, adds the Wire library for low-level I²C reads, and decodes raw CST816D touch controller registers into screen coordinates in real time.
// www.makerguides.com
// ESP32 core: 3.3.2
// Arduino_GFX_Library: 1.6.2
#include <Wire.h>
#include <Arduino_GFX_Library.h>
#define TFT_SCLK 10
#define TFT_MOSI 11
#define TFT_MISO -1
#define TFT_DC 3
#define TFT_CS 9
#define TFT_RES 14
#define TFT_BLK 46
#define LCD_PWR_EN1 1 // LCD_3V3 rail
#define LCD_PWR_EN2 2 // LEDA_3V3 rail
#define TOUCH_INT 5
#define TOUCH_SDA 6
#define TOUCH_SCL 7
#define TOUCH_RST 13
Arduino_ESP32SPI *bus = new Arduino_ESP32SPI(
TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, GFX_NOT_DEFINED, FSPI, true
);
Arduino_GFX *gfx = new Arduino_GC9A01(bus, TFT_RES, 0 /* rotation */, true /* IPS */);
int i2cRead(uint16_t addr, uint8_t reg_addr, uint8_t *reg_data, uint32_t length) {
Wire.beginTransmission(addr);
Wire.write(reg_addr);
if (Wire.endTransmission(true)) return -1;
Wire.requestFrom((int)addr, (int)length, (int)true);
for (uint32_t i = 0; i < length && Wire.available(); i++) {
*reg_data++ = Wire.read();
}
return 0;
}
int readTouch(int *x, int *y) {
// CST816D @ 0x15, coordinates start at register 0x02
uint8_t data_raw[7] = {0};
if (i2cRead(0x15, 0x02, data_raw, 7) != 0) return 0;
// data_raw[1] bits 7..6 = event (0:down,1:up,2:contact), low nibble high bits of X
int event = data_raw[1] >> 6;
if (event == 2 || event == 0) { // treat contact or down as a valid touch
*x = (int)data_raw[2] + (int)(data_raw[1] & 0x0F) * 256;
*y = (int)data_raw[4] + (int)(data_raw[3] & 0x0F) * 256;
return 1;
}
return 0;
}
void initPins() {
// Power rails for the LCD/backlight
pinMode(LCD_PWR_EN1, OUTPUT);
pinMode(LCD_PWR_EN2, OUTPUT);
digitalWrite(LCD_PWR_EN1, HIGH);
digitalWrite(LCD_PWR_EN2, HIGH);
// Backlight
pinMode(TFT_BLK, OUTPUT);
analogWrite (TFT_BLK, 100);
// Touch
pinMode(TOUCH_INT, INPUT_PULLUP);
pinMode(TOUCH_RST, OUTPUT);
// Reset the CST816D
digitalWrite(TOUCH_RST, LOW);
delay(5);
digitalWrite(TOUCH_RST, HIGH);
delay(50);
Wire.begin(TOUCH_SDA, TOUCH_SCL);
}
void initDisplay() {
delay(20);
gfx->begin();
gfx->fillScreen(WHITE);
gfx->setTextColor(BLACK);
gfx->setTextSize(3);
gfx->setCursor(80, 105);
gfx->print(F("Touch"));
}
void setup(void) {
Serial.begin(115200);
initPins();
initDisplay();
}
void loop() {
static int x, y;
if (readTouch(&x, &y) == 1) {
gfx->fillCircle(x, y, 5, RED);
}
}
Imports
Two libraries are included at the top of the program.Wire.h handles communication over the I²C bus, which is used to talk to the touch controller.Arduino_GFX_Library.h manages SPI communication with the GC9A01 round TFT display, allowing text and shapes to be drawn easily.
#include <Wire.h> #include <Arduino_GFX_Library.h>
Pin Definitions
This section defines all pin assignments for the display, touch controller, and power rails.
The SPI pins connect the ESP32 to the GC9A01 TFT controller, while the I²C pins (TOUCH_SDA and TOUCH_SCL) communicate with the CST816D touch chip. Separate enable pins control the 3.3 V rails powering the LCD logic (LCD_PWR_EN1) and the LED backlight (LCD_PWR_EN2).
#define TFT_SCLK 10 #define TFT_MOSI 11 #define TFT_MISO -1 #define TFT_DC 3 #define TFT_CS 9 #define TFT_RES 14 #define TFT_BLK 46 #define LCD_PWR_EN1 1 // LCD_3V3 rail #define LCD_PWR_EN2 2 // LEDA_3V3 rail #define TOUCH_INT 5 #define TOUCH_SDA 6 #define TOUCH_SCL 7 #define TOUCH_RST 13
Display Bus and Driver Objects
The Arduino_GFX library separates the display driver into two objects. Arduino_ESP32SPI defines the SPI bus configuration for the ESP32’s FSPI peripheral, including the data and control pins. Arduino_GC9A01 represents the specific display controller and manages commands like initialization, color fills, and text rendering.
Arduino_ESP32SPI *bus = new Arduino_ESP32SPI( TFT_DC, TFT_CS, TFT_SCLK, TFT_MOSI, GFX_NOT_DEFINED, FSPI, true ); Arduino_GFX *gfx = new Arduino_GC9A01(bus, TFT_RES, 0 /* rotation */, true /* IPS */);
This configuration matches the CrowPanel’s round 240×240 IPS display.
The i2cRead() Helper Function
This function provides a general-purpose way to read one or more registers from any I²C device.
It starts communication with the specified device address, writes the register to read, and then requests a number of bytes from the device. Each byte is stored in the buffer passed via reg_data. If any step fails, it returns -1 to signal an error.
int i2cRead(uint16_t addr, uint8_t reg_addr, uint8_t *reg_data, uint32_t length) {
Wire.beginTransmission(addr);
Wire.write(reg_addr);
if (Wire.endTransmission(true)) return -1;
Wire.requestFrom((int)addr, (int)length, (int)true);
for (uint32_t i = 0; i < length && Wire.available(); i++) {
*reg_data++ = Wire.read();
}
return 0;
}
This abstraction keeps the readTouch() function cleaner and focused on interpreting touch data rather than managing bus transactions.
Reading Touch Data with readTouch()
The CST816D touch controller provides touch event and coordinate data over I²C at address 0x15.
This function reads seven bytes starting from register 0x02, which contain information about the current touch state and coordinates.
If the event code indicates either a touch down or contact event (values 0 or 2), the function decodes the X and Y positions by combining the high and low bits of the coordinate registers.
Valid coordinates are returned through the pointers x and y, and the function returns 1 to signal a valid touch.
int readTouch(int *x, int *y) {
uint8_t data_raw[7] = {0};
if (i2cRead(0x15, 0x02, data_raw, 7) != 0) return 0;
int event = data_raw[1] >> 6;
if (event == 2 || event == 0) {
*x = (int)data_raw[2] + (int)(data_raw[1] & 0x0F) * 256;
*y = (int)data_raw[4] + (int)(data_raw[3] & 0x0F) * 256;
return 1;
}
return 0;
}
This low-level register parsing follows the CST816D datasheet, where the upper bits of each coordinate byte are stored in the previous byte’s lower nibble.
Initializing Power and I/O with initPins()
Before the display or touch controller can operate, the appropriate GPIOs must be configured.
This function sets up the LCD power rails, backlight PWM, and touch interface pins. Both LCD power-enable lines are driven high to supply 3.3 V to the display logic and LEDs. The backlight pin (TFT_BLK) is configured for PWM control, initialized to a modest duty cycle of 100 for visible brightness.
The touch interrupt line is configured as an input with an internal pull-up resistor. The CST816D reset pin is toggled low for a few milliseconds, then released high to properly restart the touch controller. Finally, the I²C bus is initialized on the specified SDA and SCL pins.
void initPins() {
pinMode(LCD_PWR_EN1, OUTPUT);
pinMode(LCD_PWR_EN2, OUTPUT);
digitalWrite(LCD_PWR_EN1, HIGH);
digitalWrite(LCD_PWR_EN2, HIGH);
pinMode(TFT_BLK, OUTPUT);
analogWrite(TFT_BLK, 100);
pinMode(TOUCH_INT, INPUT_PULLUP);
pinMode(TOUCH_RST, OUTPUT);
digitalWrite(TOUCH_RST, LOW);
delay(5);
digitalWrite(TOUCH_RST, HIGH);
delay(50);
Wire.begin(TOUCH_SDA, TOUCH_SCL);
}
Display Initialization
After a brief delay to ensure power stability, the GC9A01 display is initialized. The screen is cleared to white, the text color is set to black, and a “Touch” label is drawn in the center to indicate readiness.
void initDisplay() {
delay(20);
gfx->begin();
gfx->fillScreen(WHITE);
gfx->setTextColor(BLACK);
gfx->setTextSize(3);
gfx->setCursor(80, 105);
gfx->print(F("Touch"));
}
This setup creates a clear canvas for drawing touch indicators later in the main loop.
Setup
The setup() function is straightforward. It initializes the serial interface for debugging, then calls both initPins() and initDisplay() to prepare the hardware.
void setup(void) {
Serial.begin(115200);
initPins();
initDisplay();
}
At this stage, the display is active and the touch controller is ready to report coordinates.
Loop
The main loop continuously checks for touch input using the readTouch() function. If a valid touch is detected, it draws a small red circle at the reported coordinates using the fillCircle() function from the Arduino_GFX library. Each touch creates a new dot, allowing the user to “draw” on the screen.
void loop() {
static int x, y;
if (readTouch(&x, &y) == 1) {
gfx->fillCircle(x, y, 5, RED);
}
}
This minimal loop highlights how responsive the CST816D is when accessed through I²C and demonstrates how easily graphical feedback can be rendered on the GC9A01 display.
Conclusions
This tutorial provided you with code examples to get started with the CrowPanel 1.28inch-HMI ESP32 Rotary Display.
For more examples see Elecrows’s Github repo for the 1.28inch-HMI ESP32 Rotary Display and the Wiki Page. Note, however, that many of code examples there use the ui and lvgl library, which I avoided here to keep the complexity low.
If you are looking for a similar display module with a rotary encoder ring have look at the Matouch 1.28″ ToolSet_Controller. It does not have the RGB LED ring but a Real-Time-Clock (RTC) and an vibration motor for haptic feedback instead. And should you need only a round display (without the rotary encoder ring), the Digital Clock on CrowPanel 1.28″ Round Display tutorial might be useful.
Also, if you want to learn more about rotary encoders, have a look at our How To Interface A Quadrature Rotary Encoder tutorial.
If you have any questions feel free to leave them in the comment section.
Happy Tinkering 😉
Stefan is a professional software developer and researcher. He has worked in robotics, bioinformatics, image/audio processing and education at Siemens, IBM and Google. He specializes in AI and machine learning and has a keen interest in DIY projects involving Arduino and 3D printing.


Matt
Saturday 31st of January 2026
Hi! Thanks for the detailed tutorial! It was a huge help, especially when the Elecrow examples are so complicated and not well explained. I have some issues with your display example. Not sure if it’s an updated library or what, but I had to change how the colors are defined to compile it. But even then I can not upload and run the code. Would you be so kind and checked if your examples still work with latest versions of IDE, boards and libs?
matt
Monday 2nd of February 2026
@Stefan Maetschke, thank you! That was it, now it works. FIY, I tried `Arduino_GFX_Library: 1.6.4` and had to replace colors to W3C standard (`RED` → `RGB565_RED`) to make it work on the latest lib. Not sure what changes the latest `ESP32 core` requires.
Stefan Maetschke
Sunday 1st of February 2026
Hi, Thanks for the feedback!
Indeed, with the current ESP32 core 3.3.6 and the new Arduino_GFX_Library version 1.6.4 the code does not compile.
I found you have to downgrade to the following versions to make the code work: ESP32 core: 3.3.2 Arduino_GFX_Library: 1.6.2