Skip to content

Lesson01_RotaryScreen_LVGL: ESP32-S3 Rotary Display, Touch, and Encoder Interaction

1. Lesson Introduction

This lesson uses Arduino IDE 2.3.6 and ESP32 Arduino Core 3.3.8 to drive an ESP32-S3 1.46-inch 360 × 360 rotary display. The project controls the ST77961 LCD through LovyanGFX, reads touch coordinates through the CST816T, implements the graphical interface using LVGL 9.1.0 and a UI exported from SquareLine Studio 1.5.3, and uses separate FreeRTOS tasks to handle the rotary encoder and eight NeoPixel RGB LEDs.

After the program is uploaded and the board is reset, the screen first displays a startup page with a rotating icon, followed by a fade-in transition to the main menu. The main menu contains three options: Volume, Bulb, and Light. Rotate the knob to switch the current selection, and single-click it to open the corresponding detail page. On a detail page, rotate the knob or touch the arc control to adjust the value in 5% increments. Double-click the knob or touch the Return button to return to the main menu. The Bulb value synchronously controls the PWM output on GPIO 43, while the Light value synchronously changes the screen backlight brightness. At the same time, the eight onboard RGB LEDs cycle through running-light, flashing, and multicolor breathing effects.

The focus of this lesson is not to introduce a particular LVGL API in isolation, but to verify the entire signal chain—from display power, SPI DMA screen updates, I2C touch input, and rotary encoder input to LVGL page interaction and PWM output. Note that the Volume page currently changes only the displayed value and is not connected to audio playback functionality. The touch-coordinate debugging macro is disabled by default, so touch coordinates are not printed to the serial port.

2. Learning Objectives

  • Configure the ESP32-S3, 16 MB Flash, OPI PSRAM, and Arduino Core version according to the project requirements.
  • Explain the data relationships among the ST77961 display, CST816T touch controller, LVGL flush callback, and SquareLine UI.
  • Compile and upload the project, and determine whether the display chain is working correctly by checking the startup page, main menu, and detail pages.
  • Use rotation, single-click, double-click, and touch operations to select pages, adjust values, and return to the previous page.
  • Determine whether each function is working correctly based on UI values, GPIO 43 PWM output, screen backlight, NeoPixel lighting effects, and serial logs.

3. Required Materials

3.1 Hardware

  • ESP32-S3 1.46-inch 360 × 360 rotary display development board, 1.
  • USB data cable that supports data transfer, 1.
  • Computer, 1.

This project uses the onboard ST77961 LCD, CST816T touch controller, rotary encoder, encoder push button, and eight NeoPixel RGB LEDs. No external modules are required for these components.

3.2 Software and Versions

  • Arduino IDE 2.3.6.
  • ESP32 Arduino Core 3.3.8.
  • LVGL 9.1.0.
  • LovyanGFX 1.2.7.
  • Adafruit NeoPixel 1.15.1.
  • CST816T 1.5.1.
  • Adafruit BusIO 1.17.4, Adafruit GFX Library 1.12.4, UI files, and other dependencies are included in the project.

3.3 Required Project Files

  • RotaryScreen_1_46_Code_Core3_LVGL9.ino: Main program.
  • RotaryScreen_1_46.h: Pin assignments, display bus, panel parameters, and global state configuration.
  • libraries/UI/: LVGL 9.1 UI, image assets, and page code exported from SquareLine Studio.
  • libraries/lvgl/, libraries/LovyanGFX/, libraries/cst816t/, libraries/Adafruit_NeoPixel/: Project dependencies.

Do not copy only the .ino file. If the header file in the same directory or libraries/UI/ is missing, the project cannot display the UI used in this lesson. Mixing in another major LVGL version may also cause API or generated-code incompatibilities.

4. Software Procedure

Arduino software installation guide: https://www.elecrow.com/wiki/Get_Started_with_Arduino_IDE.html

Select ESP32 version 3.3.8. Code repository: https://github.com/Elecrow-RD/CrowPanel-1.46inch-HMI-ESP32-Rotary-Display/tree/master/example/V1.0/Arduino/RotaryScreen_1_46_Code_Core3_LVGL9

Step01: Copy the complete RotaryScreen_1_46_Code_Core3_LVGL9 project to a working directory without special permission restrictions. Confirm that the .ino and .h files remain in the same project directory. Place the files in libraries/ in the Arduino IDE library directory. Confirm the complete project directory

Confirm the library file location

Step02: Start Arduino IDE 2.3.6, select File > Open, and open RotaryScreen_1_46_Code_Core3_LVGL9.ino. After opening it, both the main program and the header file in the same directory should be visible. Open the main program in Arduino IDE

Step03: Open Boards Manager and install or confirm that esp32 by Espressif Systems is version 3.3.8. If other versions are also installed on the computer, use the version validated for this project.

Confirm the ESP32 Arduino Core version

Step04: Check the dependency versions: LVGL 9.1.0, LovyanGFX 1.2.7, Adafruit NeoPixel 1.15.1, and CST816T 1.5.1. If the project already includes the packaged dependencies, do not load other versions with the same names, as Arduino IDE may select the wrong libraries.

Confirm project dependency library versions

Step05: Expand the Partition Table

Because the rotary screen's Arduino IDE code is combined with the UI files generated by SquareLine Studio, the overall project is fairly large, and the partition table provided in the current ESP32 library is insufficient to support running the entire project, so the partition table needs to be expanded.

You can first download the partition table content we provide:

https://github.com/Elecrow-RD/CrowPanel-1.46inch-HMI-ESP32-Rotary-Display/tree/master/example/V1.0/Arduino

After downloading the partition table file, open your local ESP32 download path.

ESP32 package folder

Open the boards.txt file in the partition table file we provide at the link above, and copy its contents.

esp32s3.menu.PartitionScheme.elecrow_s3=elecrow_s3 (10.0MB APP with OTA/1.31MB SPIFFS)
esp32s3.menu.PartitionScheme.elecrow_s3.build.partitions=elecrow_s3
esp32s3.menu.PartitionScheme.elecrow_s3.upload.maximum_size=10485760

Then open the boards.txt in your local path.

boards.txt location

Copy the content above to the corresponding location; do not place it arbitrarily—refer to the position shown in the figure.

Partition settings in boards.txt

Then go to this path and copy the partition table file we provide into it.

ESP32 partitions folder

Then close all your Arduino IDE programs, and we recommend restarting the computer.

**Step06:**Open the Code, Set Parameters, and Upload

Confirm the complete project directory

Set the following parameters in the Tools menu: CPU Frequency to 240 MHz (WiFi), Flash Mode to QIO 80 MHz, Flash Size to 16 MB (128 Mb), Partition Scheme to Huge APP (3 MB No OTA / 1 MB SPIFFS), PSRAM to OPI PSRAM, USB CDC On Boot to Disabled, Upload Mode to UART0 / Hardware CDC, and Upload Speed to 921600.

Configure Flash, PSRAM, and upload parameters

Step07: Connect the rotary display using the factory-supplied USB data cable, and select the newly detected serial port under Tools > Port. Click Verify. After compilation is complete, the console should not show errors indicating that lvgl.h, LovyanGFX.h, cst816t.h, or ui.h is missing.

USB connection

Select the serial port and verify the project

Step08: Click Upload. If the development board does not automatically enter download mode, follow the board-specific download procedure to enter Boot mode and try again. After the upload is complete, open Serial Monitor, set the baud rate to 115200, and then reset the development board.

Upload the program and open Serial Monitor

5. Hardware Procedure

Step01: With the development board powered off, inspect the USB port, screen, knob, and surrounding RGB LEDs for damage. Confirm that no metallic foreign objects could cause a short circuit.Use a USB cable that supports data transfer to connect the rotary display to the computer.

Step02: After powering it on, observe the power indicator and screen area. Do not repeatedly connect and disconnect the USB cable immediately. Perform basic operations and verify whether they meet the requirements.

Connect the development board using a USB data cable

Connect the development board using a USB data cable

Arduino upload result

6. Key Code Explanations

6.1 Board Startup and Pin Preparation

pinMode(1, OUTPUT); digitalWrite(1, HIGH);
pinMode(2, OUTPUT); digitalWrite(2, HIGH);
pinMode(17, OUTPUT); digitalWrite(17, HIGH);
pinMode(ENCODER_A_PIN, INPUT);
pinMode(ENCODER_B_PIN, INPUT);
pinMode(SWITCH_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(SWITCH_PIN), buttonISR, FALLING);

In setup(), GPIO1 and GPIO2 enable the display power rails, while GPIO17 enables the NeoPixel ring. The encoder channels are configured as inputs, the push button uses its internal pull-up resistor, and a falling edge triggers buttonISR().

6.2 LCD, LVGL, and DMA Display Flush

const uint32_t screenWidth  = 360;
const uint32_t screenHeight = 360;

size_t buffer_size = screenWidth * screenHeight * 2;
buf = (uint8_t *)heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
buf1 = (uint8_t *)heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);

display = lv_display_create(screenWidth, screenHeight);
lv_display_set_color_format(display, LV_COLOR_FORMAT_RGB565);
lv_display_set_flush_cb(display, my_disp_flush);
lv_display_set_buffers(display, buf, buf1, buffer_size, LV_DISPLAY_RENDER_MODE_PARTIAL);

void my_disp_flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) {
  if (gfx.getStartCount() > 0) gfx.endWrite();
  gfx.pushImageDMA(area->x1, area->y1,
                   area->x2 - area->x1 + 1,
                   area->y2 - area->y1 + 1,
                   (lgfx::rgb565_t *)px_map);
  gfx.waitDMA();
  lv_display_flush_ready(disp);
}

The display is 360 × 360 pixels. RGB565 uses two bytes per pixel, so each full buffer needs about 259,200 bytes. Two buffers are allocated in PSRAM to preserve internal RAM. LVGL calls my_disp_flush() whenever a region needs to be redrawn; LovyanGFX transfers that rectangle through SPI DMA, waits for the transfer to finish, and then releases the buffer with lv_display_flush_ready(). Allocation errors usually indicate incorrect PSRAM or color-depth settings.

6.3 CST816T Touch Input

wi->setPins(6,7);
wi->begin();
touch.begin(mode_touch);

lv_indev_t *indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, my_touchpad_read);

void my_touchpad_read(lv_indev_t *indev, lv_indev_data_t *data) {
  if (touch.available()) {
    data->state = LV_INDEV_STATE_PRESSED;
    if (touch.x == 0 && touch.y == 0) return;
    data->point.x = touch.x;
    data->point.y = touch.y;
  } else {
    data->state = LV_INDEV_STATE_RELEASED;
  }
}

The CST816T controller uses GPIO6 and GPIO7 for I2C. It is registered as an LVGL pointer device, so LVGL calls my_touchpad_read(). A valid sample reports coordinates and a pressed state; when no sample is available, the function reports the released state. The (0,0) sample is ignored.

6.4 Creating the UI and Arc Controls

ui_init();

lv_obj_set_style_arc_image_src(ui_VolumeArc, NULL, LV_PART_MAIN);
lv_obj_set_style_arc_image_src(ui_VolumeArc, NULL, LV_PART_INDICATOR);
lv_obj_set_style_arc_color(ui_VolumeArc, lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_arc_color(ui_VolumeArc, lv_color_hex(0x33DCFF), LV_PART_INDICATOR);

lv_obj_set_style_arc_color(ui_BulbArc, lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_arc_color(ui_BulbArc, lv_color_hex(0xFF8A33), LV_PART_INDICATOR);

lv_obj_set_style_arc_color(ui_LightArc, lv_color_hex(0xFFFFFF), LV_PART_MAIN);
lv_obj_set_style_arc_color(ui_LightArc, lv_color_hex(0xFFE83D), LV_PART_INDICATOR);

lv_arc_set_range(ui_VolumeArc, 0, 100);
lv_arc_set_range(ui_BulbArc,   0, 100);
lv_arc_set_range(ui_LightArc,  0, 100);

lv_arc_set_value(ui_VolumeArc, 50);
lv_arc_set_value(ui_BulbArc,   50);
lv_arc_set_value(ui_LightArc,  50);

ui_init() creates the startup page, the main menu, and the three detail pages generated by SquareLine Studio. All three arcs use a 0–100 range and start at 50. Because image-based arc textures are unreliable with this LVGL 9.1 rendering path, setup() disables those textures and draws native arcs: white tracks with blue, orange, and yellow indicator colors.

6.5 Percentage Labels and PWM Outputs

int value = lv_arc_get_value(arc);
char bulbText[8];
snprintf(bulbText, sizeof(bulbText), value == 100 ? "%d%%" : " %d%%", value);
lv_label_set_text(ui_BulbNum, bulbText);

int pwm_value = (value * 255) / 100;
ledcWrite(BULB_LED_PIN, pwm_value);

This excerpt from bulb_arc_event_cb() reads the Bulb arc value, formats the percentage label, converts the 0–100 value to the 8-bit PWM range, and writes the result to BULB_LED_PIN (GPIO43).

6.6 Encoder Task, Event Queue, and Page Switching

encoderActionQueue = xQueueCreate(16, sizeof(EncoderAction));
xTaskCreatePinnedToCore(encTask, "ENC", 2048, NULL, 1, &encTaskHandle, 0);

EncoderAction action;
while (encoderActionQueue &&
       xQueueReceive(encoderActionQueue, &action, 0) == pdTRUE) {
  handleEncoderAction(action);
}

The queue stores up to 16 EncoderAction items, and encTask() runs on Core 0. The Arduino loop receives each queued action and passes it to handleEncoderAction(). This keeps the task that reads the encoder separate from the code that updates LVGL.

6.7 Main Loop and NeoPixel Effects

led.setBrightness(25);
xTaskCreatePinnedToCore(ledTestTask, "LED Test", 2048, NULL, 1,
                        &ledTestTaskHandle, 0);

EncoderAction action;
while (encoderActionQueue &&
       xQueueReceive(encoderActionQueue, &action, 0) == pdTRUE) {
  handleEncoderAction(action);
}

lv_timer_handler();
vTaskDelay(pdMS_TO_TICKS(5));

The NeoPixel brightness is set to 25, and ledTestTask() runs separately on Core 0. In loop(), queued encoder actions are handled before lv_timer_handler() processes LVGL timers, input, events, and redraws. The 5 ms delay yields processor time before the next loop cycle.

7. UI Asset Integration Process

This project provides UI C files already exported from SquareLine Studio 1.5.3, but it does not include the original editable SquareLine project. Therefore, this section only explains how the existing assets are integrated and does not fabricate the original control-design process. If the original project becomes available later, complete creation screenshots should be added based on a resolution of 360 × 360, LVGL 9.1.0, and a 16-bit color depth.

UI project repository: https://github.com/Elecrow-RD/CrowPanel-1.46inch-HMI-ESP32-Rotary-Display/tree/master/example/V1.0/Arduino/1_46_SquareLine_Studio_Project

SquareLine Studio operating guide: https://www.elecrow.com/wiki/Get_Started_with_SquareLine_Studio.html

Open the provided project using SquareLine Studio. Confirm the LVGL version and dimensions. Check UI library metadata

8. Expected Results

8.1 Power-On and Startup

  • After USB power is applied, GPIO 1 and GPIO 2 are driven high to enable screen power, while GPIO 40 is driven low to illuminate the active-low power indicator.
  • After the LCD is reset, it is first filled with black, and the backlight is enabled at approximately a 50% duty cycle.
  • The Screen0 startup page appears, and the center icon performs multiple rotation animations. When they are complete, the UI transitions to the main menu with a fade-in effect.
  • If the serial port outputs Failed to allocate for LVGL buf! or Failed to allocate for LVGL buf1!, PSRAM buffer allocation has failed, and the experiment cannot be considered successful.

8.2 Main Menu and Knob

  • The main menu displays three options: Volume, Bulb, and Light. The middle option, Bulb, is selected initially.
  • When the knob is rotated, the blue selection moves among the three options and does not move beyond the first or last item.
  • The serial port outputs content similar to the following. The specific direction text depends on the physical installation orientation of the knob:
Direction: CCW | Counter: 1
cur_index : 2
  • After the knob is single-clicked, the program waits for the double-click detection window to expire before opening the currently selected detail page. Double-clicking returns from any detail page to the main menu.

8.3 Three Detail Pages

  • Volume: The initial value is 50%. The knob or touch input can adjust it from 0% to 100%. It only changes the UI and does not produce sound.

  • Bulb: The initial displayed value is 50%. When the value changes, the GPIO 43 PWM output changes synchronously. When the program initializes GPIO 43, the raw PWM value written is 50. It is not converted from the percentage to the 0–255 range until the Arc callback is triggered for the first time.

  • Light: The initial displayed value is 50%. When the value changes, the GPIO 46 backlight PWM output changes synchronously. At 0%, the screen may appear completely black, but the program may still be running.

  • On all three pages, touch Return to go back. You cannot switch directly among the Volume, Bulb, and Light pages; you must return to the main menu first.

Rotary display running the LVGL interface

8.4 RGB LEDs and Stability

The eight NeoPixels should cycle through a white running-light effect, rapid multicolor flashing, a multicolor running-light effect, slow multicolor flashing, and a breathing effect. While the display animation, knob input, and LED animations are running simultaneously, the device should not reset repeatedly, remain stuck on one page for an extended period, or stop responding. The current code does not print touch coordinates by default; this is normal.

9. Common Issues and Troubleshooting

9.1 lvgl.h Is Not Found During Compilation or LVGL API Errors Occur

  • Confirm that LVGL 9.1.0 is being used, rather than LVGL 8.x or another 9.x version.
  • Confirm that Arduino IDE is not prioritizing another library with the same name in the sketchbook.
  • Keep the complete libraries/UI/ directory; do not copy only the .ino file.

9.2 Compilation Reports an Incorrect Color Depth

The UI code requires LV_COLOR_DEPTH to be 16. Check the active lv_conf.h. Do not place the configuration file in a location Arduino cannot read, and do not retain multiple conflicting lv_conf.h files.

9.3 Upload Fails or the Port Cannot Be Found

  • Replace the USB cable with one confirmed to support data transfer.
  • Reselect ESP32S3 Dev Module and the correct serial port.
  • If necessary, follow the board's Boot/Reset procedure to enter download mode.
  • If 921600 is unstable, temporarily reduce the upload speed. This affects only the upload time and does not change program functionality.

9.4 The Screen Is Completely Black

Troubleshoot in the following order:

  1. Confirm that GPIO 1 and 2 have enabled screen power.
  2. Confirm that the GPIO 46 backlight has not been set to 0% from the Light page.
  3. Confirm the GPIO 14 reset timing and the ST77961 SPI pins.
  4. Check the serial output for PSRAM buffer-allocation failure messages.
  5. Confirm that both ui_init() and lv_timer_handler() are executed.

9.5 The UI Is Visible but Touch Does Not Respond

  • Check whether the CST816T uses the I2C bus on GPIO 6 and 7 and successfully calls touch.begin(mode_touch).
  • Confirm that the LVGL input device has registered my_touchpad_read().
  • Do not touch the screen with sharp objects.
  • To troubleshoot coordinates, temporarily enable DEBUG_PRINT in the code and recompile. Restore the original setting after verification.

9.6 Knob Direction Is Reversed, Input Jumps, or Button Presses Are Triggered Incorrectly

  • The direction text depends on the physical installation orientation. As long as menu selection and value changes are stable, operation is normal.
  • Check the connections for GPIO 45, 42, and 41.
  • A single-click must wait approximately 300 ms for the double-click detection window to expire. This behavior is intentional.
  • Do not remove the debounce logic directly, or mechanical bouncing may be recognized as multiple clicks.

10. Code Download

Code download link.