Skip to content

LVGL_IDF: 2.8-inch Touch UI, Temperature/Humidity Display, and LED Control under ESP-IDF 5.5.4

1. Course Introduction

This lesson uses ESP-IDF 5.5.4 to build a CrowPanel ESP32 2.8-inch HMI touchscreen application, and reuses APIs such as TFT_eSPI, Wire, Serial, and millis() through the Arduino-ESP32 3.3.8 component. The program initializes the ILI9341 LCD, touch, DHT20, LVGL 9.1.0, and the UI exported from SquareLine Studio 1.6.1 inside app_main(), then continuously processes the interface in a FreeRTOS loop, updates temperature and humidity every second, and controls GPIO25 based on the ON/OFF button.

After the firmware is flashed, the LCD displays the background, two sensor values, and two image buttons. When the learner taps a button, the UI's C event code modifies the shared variable led, and the C++ main program immediately updates the LED output; the touch coordinates are mirrored on the X axis in landscape orientation so that the tap position aligns with the UI. This lesson verifies the complete chain from ESP-IDF component management and LCD refreshing through touch reading to UI events and hardware output.

Reference materials:

2. Learning Objectives

  • Be able to use VS Code and the ESP-IDF extension to open, configure, compile, and flash an ESP-IDF project.
  • Be able to explain the relationship among app_main(), the Arduino compatibility layer, and the FreeRTOS loop.
  • Be able to understand the data flow among the LVGL display refresh callback, touch input callback, SquareLine UI, and GPIO25 LED control.
  • Be able to locate, by following the source code comments, the key code for LCD backlight, touch calibration, temperature/humidity label refresh, and button events.

3. What You Need to Prepare

  • One 2.8-inch ESP32 touchscreen development board.
  • One USB data cable.
  • One DHT20 temperature/humidity module, connected to GPIO22 (SDA) and GPIO21 (SCL).
  • One LED module, connected to GPIO25.
  • VS Code, the Espressif IDF extension, and ESP-IDF 5.5.4, or an ESP-IDF 5.5.4 terminal with export.ps1 already executed.
  • VS Code + ESP-IDF Extension: Click here for the installation tutorial link
  • The Windows build path must contain only ASCII characters. Before starting the experiment, copy LVGL_IDF in full to a pure-English path, for example C:/esp_projects/LVGL_IDF/; do not build directly inside the course-material path that contains Chinese characters.
  • The first configuration requires downloading or verifying managed components over the network; even when managed_components/ already exists, you should still rely on the dependency lock file.

4. Software Operation Steps

The steps below are performed mainly using VS Code's Espressif IDF extension. For first-time use, please complete them in order; do not flash directly before the target chip and serial port have been set.

  1. In VS Code, select File > Open Folder to open the project files.

image-20260722151001383

image-20260724165935373

  1. Run the Build-> Delete command to completely clear the build cache and path files left over from old projects, avoiding compilation conflicts.

image-20260724170027248

  1. Make sure that ESP-IDF 5.5.4 is already installed.

image-20260722151415231

  1. Connect the development board with a USB data cable, click the port name in the status bar, and select the COM port that actually appears for the board. The port number varies by computer; if you are unsure, unplug the board and observe which port disappears from the list.

IMG_7834

image-20260724170204464

  1. Click the target chip name in the status bar, or run ESP-IDF: Set Espressif Device Target, and select esp32 from the list.

image-20260724170249960

  1. Select the configuration suitable for the classic ESP32, such as ESP-WROVER-KIT 3.3V. This lesson uses UART flashing; OpenOCD is not involved in writing, but selecting the correct one avoids the extension repeatedly prompting for a missing configuration.

WeCom screenshot

  1. Click the Build (wrench) button in the status bar, or run idf.py build in the terminal. The first compilation takes a long time; the bottom-right corner should indicate that the project build is complete.

image-20260722152315413

image-20260724170444059

image-20260724170539635

  1. Confirm that the flashing method in the status bar is UART, then click the lightning-shaped Flash button; you can also run idf.py -p COMx flash, replacing COMx with the actual port. After writing finishes, the board resets automatically.

image-20260724170603508

image-20260724170637422

5. Hardware Operation Steps

  1. With the power off, connect the DHT20 module to the board's IIC interface, confirming that SDA maps to GPIO22 and SCL maps to GPIO21; connect the LED module to the interface labeled GPIO_D so that the signal line connects to IO25. Do not connect the signal line to a power pin, and do not plug or unplug modules while powered on.

IMG_7836

  1. Reconnect a USB cable that supports data transmission. After the upload completes and the board resets automatically, the screen backlight should light up, and the interface should show temperature, humidity, and two buttons labeled ON and OFF.

16

  1. Use your finger to tap ON and OFF in turn, and observe whether the LED lights up and goes out accordingly, while checking whether the serial monitor outputs the corresponding status. Do not press the screen with sharp or conductive objects.

ON->LED lights up

DHT20 and LED wiring

OFF->LED goes out

IMG_7838

6. Key Code Explanation

6.1 app_main() and the Arduino Compatibility Layer

/**
 * @brief Initialize the hardware and run the LVGL application task.
 *
 * ESP-IDF calls this function once after system startup. Arduino compatibility
 * is initialized first, then the function remains in its FreeRTOS loop to
 * service LVGL, sample the DHT20, and apply the UI-controlled LED state.
 */
extern "C" void app_main() {
  initArduino();
  Serial.begin(115200);

  pinMode(25, OUTPUT);
  digitalWrite(25, LOW);

  Wire.begin(22, 21);
  Wire.setClock(100000);
  delay(100);
  if (dht20.begin() != 0) {
    Serial.println("DHT20 init failed");
  }
}

When it runs and what it does: ESP-IDF calls app_main() after startup. This project is a C++ file, so the entry point uses extern "C" to be exposed to ESP-IDF; initArduino() first initializes the Arduino compatibility layer, after which APIs such as Serial, Wire, pinMode(), digitalWrite(), and millis() can be used.

Troubleshooting focus: If there is no serial output, first check Serial.begin(115200) and the monitor baud rate; if the DHT20 gives no readings, first check whether Wire.begin(22, 21) matches the hardware I2C pins.

6.2 LVGL Partial Refresh to the LCD

/**
 * @brief Transfer a rendered LVGL area to the LCD.
 *
 * LVGL calls this function whenever a region of the display buffer is ready.
 * The completion notification is essential because LVGL must not reuse the
 * buffer while the transfer is still in progress.
 */
void my_disp_flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) {
  uint32_t w = (area->x2 - area->x1 + 1);
  uint32_t h = (area->y2 - area->y1 + 1);

  lcd.startWrite();
  lcd.setAddrWindow(area->x1, area->y1, w, h);
  lcd.pushColors((uint16_t *)px_map, w * h, true);
  lcd.endWrite();

  lv_display_flush_ready(disp);
}

When it runs and what it does: After LVGL finishes drawing a local region, it calls my_disp_flush() to write the RGB565 pixels in px_map to the LCD. area is a rectangular region that includes the boundary pixels, so both the width and height need +1.

Troubleshooting focus: lv_display_flush_ready(disp) must not be omitted. It indicates that this screen refresh is complete so that LVGL can continue using the buffer; if omitted, the interface may freeze after the first refresh.

6.3 Touch Calibration and LVGL Input Events

// Maps raw resistive-touch readings to this panel's screen coordinates.
uint16_t calData[5] = {189, 3416, 359, 3439, 1};

/**
 * @brief Convert the current panel touch into an LVGL pointer event.
 *
 * LVGL calls this function while processing input. TFT_eSPI applies calData,
 * so the coordinates can be passed directly to the landscape LVGL display.
 */
void my_touchpad_read(lv_indev_t *indev, lv_indev_data_t *data) {
  bool touched = lcd.getTouch(&touchX, &touchY, 400);
  if (!touched) {
    data->state = LV_INDEV_STATE_RELEASED;
  } else {
    data->state = LV_INDEV_STATE_PRESSED;
    data->point.x = touchX;
    data->point.y = touchY;
  }
}

When it runs and what it does: When lv_timer_handler() processes input devices, it polls my_touchpad_read(). lcd.setTouch(calData) has already calibrated the raw resistive-touch values into screen coordinates, so here touchX and touchY are passed directly to LVGL.

Troubleshooting focus: If the interface displays but buttons do not respond, first check lcd.setTouch(calData), the touch chip-select configuration of TFT_eSPI, and whether lv_indev_set_read_cb() registered this callback.

6.4 Registering the Display, Touch, and SquareLine UI

/*---------------------------------------------------------------
 * Register display and touch callbacks
 * LVGL renders through my_disp_flush() and polls my_touchpad_read().
 *--------------------------------------------------------------*/
lv_display_t *disp = lv_display_create(screenWidth, screenHeight);
lv_display_set_flush_cb(disp, my_disp_flush);
lv_display_set_buffers(disp, buf1, NULL, sizeof(buf1), LV_DISPLAY_RENDER_MODE_PARTIAL);

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);

ui_init();

When it runs and what it does: This code runs after LCD initialization, the backlight is turned on, and the screen orientation and touch calibration are set. The display callback is responsible for sending the LVGL image to the LCD, the input callback is responsible for handing touch coordinates to LVGL, and finally ui_init() creates the background, labels, and button objects exported by SquareLine.

Troubleshooting focus: If the screen backlight is on but there is no UI, focus on checking whether ui_init() executed and whether the UI components were added to the components/UI/ directory of the ESP-IDF project.

6.5 Sensors and LED in the FreeRTOS Loop

// Records the last successful sensor polling interval boundary.
uint32_t last_sensor_ms = 0;

while (1) {
  lv_timer_handler();

  /*---------------------------------------------------------------
   * Update sensor labels once per second
   * Limiting I2C reads keeps the faster 10 ms UI service loop responsive.
   *--------------------------------------------------------------*/
  uint32_t now = millis();
  if (now - last_sensor_ms >= 1000) {
    last_sensor_ms = now;
    int temperature = 0;
    int humidity = 0;
    if (dht20.readTempHumidity(&temperature, &humidity) == 0) {
      char dht_buffer[8];
      snprintf(dht_buffer, sizeof(dht_buffer), "%d", temperature);
      lv_label_set_text(ui_Label1, dht_buffer);
      snprintf(dht_buffer, sizeof(dht_buffer), "%d", humidity);
      lv_label_set_text(ui_Label2, dht_buffer);
    }
  }

  // The UI callback changes led; this loop owns the physical GPIO update.
  digitalWrite(25, led ? HIGH : LOW);
  vTaskDelay(pdMS_TO_TICKS(10));
}

When it runs and what it does: app_main() does not exit, but keeps running inside while (1). LVGL is serviced about every 10 ms, and the DHT20 is read once every 1 second, which ensures both UI responsiveness and avoids high-frequency sensor reads.

Troubleshooting focus: Whether the temperature and humidity can be displayed mainly depends on whether dht20.readTempHumidity() returns success; whether the LED can follow the button change mainly depends on whether led is modified by the UI event and whether the GPIO25 wiring is correct.

6.6 UI Events and the C/C++ Shared Variable

// Shares the LED state with the ESP-IDF application loop.
extern int led;

/**
 * @brief Set the application LED state when the ON button is clicked.
 *
 * LVGL calls this handler for events from ui_Button1. Only the click event
 * changes the shared state; app_main() performs the GPIO write.
 */
void ui_event_Button1(lv_event_t * e)
{
    lv_event_code_t event_code = lv_event_get_code(e);

    if(event_code == LV_EVENT_CLICKED) {
        led = 1;
    }
}

/**
 * @brief Clear the application LED state when the OFF button is clicked.
 *
 * LVGL calls this handler for events from ui_Button2. Only the click event
 * changes the shared state; app_main() performs the GPIO write.
 */
void ui_event_Button2(lv_event_t * e)
{
    lv_event_code_t event_code = lv_event_get_code(e);

    if(event_code == LV_EVENT_CLICKED) {
        led = 0;
    }
}
/* main/CrowPanel_ESP32_2.8.cpp */
extern "C" int led = 0;

When it runs and what it does: The ui_Screen1.c generated by SquareLine is compiled as C, while the main program CrowPanel_ESP32_2.8.cpp is compiled as C++. The main program exposes led with extern "C", and the UI event file references the same variable with extern int led. The ON event writes 1, the OFF event writes 0, and the actual GPIO25 output is still executed uniformly by the app_main() loop.

7. UI Asset Creation and Integration

This section uses SquareLine Studio 1.6.1 to re-demonstrate how to create the interface. The course already provides the exported UI files; beginners can first read this section to understand the workflow, then use the files in the project directly to complete the build. When creating a new project, you must select LVGL 9.1.0;

How to download SquareLine Studio: https://www.elecrow.com/wiki/Create_LVGL_UI_with_SquareLine_Studio.html

  1. Launch SquareLine Studio 1.6.1,Click Create, then select the Arduino with TFT_eSPI template for the Arduino platform.This template is responsible for generating the UI file framework suitable for Arduino and TFT_eSPI projects.

Note: When you select the Arduino framework, SquareLine Studio displays only the Arduino with TFT_eSPI option. It generates template code suitable for TFT_eSPI, but SquareLine Studio also supports other graphics libraries; if you switch to different hardware, you need to modify the display code according to the actual library you use.

19

  1. Enter the project name, set the LVGL version to 9.1.0, set the resolution to width 320 and height 240, set the color depth to 16 bit, then click CREATE. After creation, the canvas should be in landscape orientation at 320×240.

image-20260722182857564

  • Note: A 16 bit color depth can represent 65,536 colors, using an RGB 5:6:5 pixel representation. Keep it consistent with the project's color configuration.

  • After the project opens, select Screen1 in the Screens panel on the left, and confirm that the central canvas is blank and that the Inspector on the right shows the Screen properties. All subsequent widgets will be added to this page.

21

  1. In the Assets area, click ADD FILE TO ASSETS and import background.png, on.png, and off.png from LVGL-Assets-320x240/ in order. After import, you should see three thumbnails.

22

Note: Image assets support only the PNG format; the pixel dimensions of an image should be smaller than the project screen size; a single image should not exceed 100 KB, and it is best to keep it within 30 KB, to avoid affecting display smoothness.

  1. Select Screen1, expand STYLE SETTINGS > STYLE (MAIN) > Background on the right, and enable the background image setting.

23

  1. Select background in Bg Image. The canvas should immediately display the course background image, and the background should fully cover the 320×240 page.

24

  1. Click Label in the Widgets panel on the left to add Label1 to Screen1. This label will be updated by the program with the temperature value.

25

  1. Select Label1, and in Transform adjust the X, Y position and the width/height so that it sits within the temperature display area of the background image. You can also drag it first and then fine-tune it using the numeric values.

26

  1. In Label1's STYLE (MAIN), change the text color to white and set an appropriate font size. The text on the canvas should be clearly visible and should not extend beyond the background frame.

27

  1. Right-click Label1 and select Duplicate to generate Label2, then move it to the humidity display area. Keep the names Label1 and Label2, because the main program will update them by name.

    28

  2. Select the two labels respectively, and enter default numbers in Text for easy preview. These are only placeholder values at design time, and will be replaced by DHT20 readings once the development board runs.

    29

  3. Click Button in Widgets to add Button1, and drag it to the ON area on the right side of the interface.

    30

  4. In Transform, set the size of Button1 and adjust its position so that the button aligns with the ON area in the background.

    31

  5. Expand Button1's STYLE (MAIN) > Background and select on.png as the background image. An ON icon should appear on the canvas.

    32

  6. Duplicate Button1 to get Button2, move it to the OFF area, and change the background image to off.png. Keep the names Button1 and Button2 to make them easier to match with the exported event functions.

    33

    34

  7. In the STATE settings of the two buttons, check the DEFAULT and PRESSED states. The pressed state can use an obvious color change so that during preview you can tell whether the button is pressed. In "Inspector" → "Style Settings" → "State", set the displayed background color to white, and when in the "Fixed State" it displays red. Set the same parameters for the "OFF" button.

    35

    36

  8. Select Button1, then click ADD EVENT.

    37

  9. Select "CLICKED" as the trigger condition, and select the trigger event in "Action". The LED control function will be implemented later by modifying the generated program.

    38

    Note: Because the button ultimately needs to control the LED on/off, you can add any event here first so that the exported UI file generates the button event code framework; the LED control code will be modified in a later step.

  10. Complete this event. Here, I choose to switch screens, that is, switch to the Screen1 screen.

    39

  11. Add the event to Button2 in the same way (state is "OFF").

    40

  12. Click Run.

    41

  13. Open File > Project Settings, then make the relevant settings for the exported files.

    42

    43

  14. Set the export directory to an easy-to-find path using only English characters, create a new output folder, confirm that the LVGL Include Path is lvgl.h, and then click APPLY CHANGES after confirmation.

    image-20260723191231439

    image-20260722183055936

    Tip: After selecting Flat export, all output files are placed in the same folder, so the program does not need any file path changes. If Flat export is not selected, files are scattered across different folders and the compiler may fail to recognize them automatically; you usually also have to modify the paths manually, so it is recommended to keep it checked.

  15. Click Export > Export UI Files. After the export is complete, ui.c, ui.h, ui_Screen1.c, event files, helper files, and image array files should appear in the target directory.

    46

    47

  16. Copy all exported UI files (.c and .h) to the UI folder under Components in the project, replacing the files with the lvgl 9.1 version.

image-20260724171218210

8. Experimental Results

  1. With the power off, connect the DHT20 module to the IIC interface of the development board, confirming that SDA corresponds to GPIO22 and SCL corresponds to GPIO21; connect the LED module to the interface marked GPIO_D so that its signal line connects to IO25. Do not connect the signal line to a power pin, and do not plug or unplug modules while powered on.

IMG_7836

  1. Reconnect a USB cable that supports data transfer. After the upload completes and the board resets automatically, the screen backlight should light up, and the interface should display the temperature, humidity, and the ON and OFF buttons.

16

  1. Use your finger to tap ON and OFF in turn, and observe whether the LED lights up and goes out accordingly, while checking whether the serial monitor outputs the corresponding status. Do not use sharp or conductive objects to press the screen.

ON → LED lights up

DHT20 and LED wiring

OFF → LED goes out

IMG_7838

9. Code Download