Skip to content

PlatformIO_LVGL_2.4: 2.4-inch Temperature and Humidity Display and Touch LED Control with PlatformIO

1. Course Introduction

This lesson uses VS Code, PlatformIO, the Arduino framework, LVGL 9.1.0, TFT_eSPI 2.5.31, and SquareLine Studio 1.6.1 to drive the CrowPanel ESP32 2.4-inch display. The program reads temperature and humidity from the DHT20 over the I2C bus on GPIO22/GPIO21, displays the values on the 320 × 240 screen through LVGL, and controls the LED on GPIO25 via an ON/OFF touch button.

Learners should first set up the development environment and hardware, open the course project, then complete compilation, serial port selection, and flashing, and finally observe the screen, LED, and serial monitor. During normal operation, the LCD backlight turns on, the temperature and humidity labels display values, the LED state changes when the button is pressed, and serial coordinates are output when touched. This lesson uses the same set of UI resources as the Arduino version; the difference is that the project is managed by PlatformIO for dependency and build processes.

Reference materials:

2. Learning Objectives

  • Be able to install PlatformIO in VS Code and open the course project.
  • Be able to explain the roles of platformio.ini, src/, include/, and lib/ in this project.
  • Be able to complete the wiring of the DHT20, LED, USB, and display, and compile, flash, and open the serial monitor following the PlatformIO workflow.
  • Be able to use SquareLine Studio 1.6.1 to create a 320 × 240 LVGL 9.1.0 UI and integrate the exported files into the PlatformIO project.
  • Be able to determine whether the experiment is successful based on the screen display, button response, LED state, and serial coordinates.

3. Preparations

3.1 Hardware

  • One CrowPanel ESP32 2.4-inch development board.
  • One DHT20 temperature and humidity module, connected to GPIO22 (SDA) and GPIO21 (SCL).
  • One LED module, connected to GPIO25.
  • A 4-pin connector cable and a USB data cable that supports data transfer.

3.2 Software and Versions

  • Visual Studio Code.
  • PlatformIO IDE extension.
  • LVGL 9.1.0.
  • SquareLine Studio 1.6.1.

4. Software Operation Steps

  1. Open VS Code, search for and install PlatformIO IDE in Extensions.

image-20260723154828563

  1. After installation, restart VS Code; the PlatformIO icon should appear on the left side. Click the icon to enter the PIO Home main page.

image-20260723154858011

  1. In the PIO Home Quick Access area, click Open Project.

image-20260723154324820

  1. Browse to the path in the popup, select the project root folder PlatformIO24 (the folder must contain platformio.ini), and click the blue button Open "PlatformIO24".

Open PlatformIO Project

  1. The project loads in the left-side Explorer

src/main.cpp: main program code

platformio.ini: platform configuration file

include and lib are dependency library directories

Open main.cpp to review the project code.

Platform flashing project software process steps

  1. Connect the CrowPanel development board to a USB port on your computer using a USB data cable.

08

  1. Select the device serial port: Check the bottom status bar of VS Code; the dropdown box on the left defaults to Auto. Click the dropdown and select the serial port corresponding to the development board (example: COM13). Platform flashing project software process steps (1)

  2. Click the right-arrow icon (PlatformIO: Upload) on the bottom status bar of VS Code. Wait for the compile and upload process to run automatically.

Platform flashing project software process steps (2)

Platform flashing project software process steps (3)

Wire the connections as shown in the diagram:

  • DHT20 temperature and humidity sensor → board I2C interface

  • LED module → board GPIO_D interface

IMG_7931

It displays real-time temperature and humidity (data collected by the DHT20) and provides ON/OFF buttons to turn the external LED on/off.

Platform flashing project software process steps (4)

ON → LED on

17

OFF → LED off

18

6. Key Code Explanation

6.1 PlatformIO Version and Pin Configuration

; Select the ESP32 board definition and Arduino framework.
board = denky32
framework = arduino

; Use the same baud rate as Serial.begin() in main.cpp.
monitor_speed = 115200

build_flags =
    ; Let LVGL find the project configuration in include/lv_conf.h.
    -DLV_CONF_INCLUDE_SIMPLE
    -Iinclude

    ; Configure the ILI9341 display and the 2.4-inch panel dimensions.
    -DILI9341_DRIVER=1
    -DTFT_WIDTH=240
    -DTFT_HEIGHT=320

    ; Configure the LCD SPI bus, backlight, and touch chip select.
    -DTFT_MISO=12
    -DTFT_MOSI=13
    -DTFT_SCLK=14
    -DTFT_CS=15
    -DTFT_DC=2
    -DTFT_BL=27
    -DTOUCH_CS=33

; Lock the library versions verified by this course project.
lib_deps =
    lvgl/lvgl@9.1.0
    bodmer/TFT_eSPI@2.5.31

When and what it does: Before compilation, PlatformIO reads platformio.ini and prepares the ESP32 Arduino build environment based on board and framework, and passes the display parameters to TFT_eSPI through build_flags. lib_deps downloads the specified versions of LVGL and TFT_eSPI on the first build.

Key parameters: monitor_speed = 115200 must match Serial.begin(115200); TFT_WIDTH and TFT_HEIGHT are the portrait dimensions of the display driver, and the program then rotates them to a 320 × 240 landscape orientation via lcd.setRotation(1); GPIO12/13/14 are the SPI data and clock lines, GPIO15 is the LCD chip select, GPIO2 is the data/command select, GPIO27 controls the backlight, and GPIO33 is the touch chip select.

Normal behavior and effect of changes: With correct parameters, the project compiles, the LCD shows the full interface, and touch responds. Changing library versions may cause API incompatibilities; changing SPI or chip-select pins may cause a black screen, display corruption, or unresponsive touch. After modifying the experiment, restore the original values from the course project.

Troubleshooting focus: During compilation, first check the library versions and include/lv_conf.h; if the display or touch is abnormal, then verify the pin macros one by one, and avoid directly modifying the library files in .pio/libdeps.

6.2 Initialize LCD, Backlight, and Touch

/*---------------------------------------------------------------
 * Initialize the LCD and touch panel.
 * This block runs once from setup() after LVGL is initialized.
 *--------------------------------------------------------------*/
lcd.begin();
lcd.fillScreen(TFT_BLACK);

// GPIO27 enables the panel backlight when driven high.
pinMode(27, OUTPUT);
digitalWrite(27, HIGH);

// Rotation 1 presents the physical 240 x 320 panel as 320 x 240.
lcd.setRotation(1);

// Apply calibration values measured for this resistive touch panel.
lcd.setTouch(calData);

When and what it does: This code runs once in setup(). lcd.begin() initializes the ILI9341 and SPI communication, turns on the backlight after clearing the screen, and then sets the landscape orientation and touch calibration parameters.

Key parameters: TFT_BLACK is only used to clear random content during initialization; the backlight turns on when GPIO27 is high; the rotation value 1 corresponds to the 320 × 240 LVGL display size; the 5 values in calData are used to map raw touch values to screen coordinates.

Normal behavior and effect of changes: After normal execution, the backlight turns on and the landscape UI becomes visible. If digitalWrite(27, HIGH) is commented out, the LCD may still be receiving image data, but the screen appears black; if the rotation value does not match, the interface orientation and touch position will be misaligned.

Troubleshooting focus: For a black screen, first check the USB power supply and the GPIO27 backlight, then check the LCD SPI pins; if the interface is visible but the tap position is inaccurate, check setRotation(1) and calData rather than modifying the LVGL widget positions first.

6.3 Register LVGL Display and Input Callbacks

/*---------------------------------------------------------------
 * Register the LVGL display.
 * LVGL renders a small area into buf1 and asks my_disp_flush()
 * to transfer that area to the LCD.
 *--------------------------------------------------------------*/
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);

/*---------------------------------------------------------------
 * Register the LVGL pointer input device.
 * LVGL polls my_touchpad_read() while processing its timers.
 *--------------------------------------------------------------*/
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);

// Create and load the labels, buttons, and image resources.
ui_init();

When and what it does: This code runs once after the LCD is initialized. The first block creates the LVGL display device and registers the partial-flush callback; the second block creates the pointer input device and registers the touch callback; the final ui_init() loads the interface exported from SquareLine Studio.

Key parameters: screenWidth and screenHeight are 320 and 240 respectively; sizeof(buf1) is the byte size of the partial drawing buffer; LV_DISPLAY_RENDER_MODE_PARTIAL means LVGL draws in blocks and does not need to allocate a buffer for the entire screen; the input type must be LV_INDEV_TYPE_POINTER to represent touch coordinates.

Normal behavior and effect of changes: When all three parts are correct, the interface displays and responds to touch. Without the flush callback, LVGL may still run but the LCD shows no correct image; without the touch callback, the UI can display but the buttons do not respond; without ui_init(), the backlight may turn on, but the labels and buttons for this lesson will not be created.

Troubleshooting focus: When the interface does not display, check my_disp_flush(), the buffer, and the ui*.c files; when the interface is normal but buttons do not respond, check my_touchpad_read(), GPIO33, and the touch calibration values.

6.4 Flush LVGL Rendering Results to the LCD

/**
 * Transfer one LVGL-rendered rectangle to the LCD.
 * LVGL calls this function whenever a partial buffer is ready.
 */
void my_disp_flush(lv_display_t * disp,
                   const lv_area_t * area,
                   uint8_t * px_map)
{
    // LVGL area coordinates include both boundary pixels.
    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(reinterpret_cast<uint16_t *>(px_map), w * h, true);
    lcd.endWrite();

    // Release the buffer only after the LCD transfer has completed.
    lv_display_flush_ready(disp);
}

When and what it does: LVGL automatically calls this function after completing the rendering of a partial area. The function calculates the region width and height from area, sends the RGB565 pixels in px_map to the ILI9341, and finally notifies LVGL that this flush has completed.

Key parameters: area->x1/y1 is the top-left corner and area->x2/y2 is the bottom-right corner; the boundary coordinates are both included in the flush region, so the width and height calculation must add 1; px_map points to the LVGL drawing buffer; w * h is the number of pixels transferred this time.

Normal behavior and effect of changes: When called normally, the interface refreshes completely. If 1 is omitted from the width/height, edge loss or image misalignment may occur; if lv_display_flush_ready() is missing, LVGL will keep waiting for the buffer to be released and the interface may freeze on the first frame.

Troubleshooting focus: When display corruption or partial non-refresh occurs, first check the screen color depth, the region width and height, the pixel count in pushColors(), and whether lv_display_flush_ready() is executed.

6.5 Pass Touch Status to LVGL

/**
 * Convert one TFT_eSPI touch sample into an LVGL pointer event.
 * LVGL calls this function repeatedly from lv_timer_handler().
 */
void my_touchpad_read(lv_indev_t * indev, lv_indev_data_t * data)
{
    (void)indev;
    bool touched = lcd.getTouch(&touchX, &touchY, 600);

    if(!touched) {
        // Release prevents LVGL from treating the previous point as held.
        data->state = LV_INDEV_STATE_RELEASED;
    }
    else {
        data->state = LV_INDEV_STATE_PRESSED;
        data->point.x = touchX;
        data->point.y = touchY;

        // Serial output helps verify calibration and touch direction.
        Serial.print("Data x ");
        Serial.println(touchX);
        Serial.print("Data y ");
        Serial.println(touchY);
    }
}

When and what it does: While lv_timer_handler() runs, LVGL repeatedly calls this callback. lcd.getTouch() reads the touch state and coordinates, and the program then writes the PRESSED/RELEASED state and coordinates into data.

Key parameters: touchX and touchY hold the calibrated coordinates; 600 is the TFT_eSPI touch pressure threshold—too low a value may cause false touches, and too high may prevent light touches from being recognized; the current calibration data matches the rotation value 1, so the coordinates do not need to be mirrored again.

Normal behavior and effect of changes: When the screen is pressed, the button responds and the serial port outputs Data x and Data y; when released, LVGL receives RELEASED. If RELEASED is always returned, the button will not respond; if x and y are swapped or the coordinates are incorrectly mirrored, the tap position will not match the display.

Troubleshooting focus: First observe whether the serial port outputs coordinates. If there are no coordinates, check the touch chip select GPIO33 and the pressure threshold; if there are coordinates but the widget does not respond, check the coordinate range, rotation direction, and input device registration.

6.6 Read DHT20 and Update Labels

// Reserve enough space for an integer value and its string terminator.
char DHT_buffer[12];

// Read the sensor values through the I2C bus on GPIO22 and GPIO21.
int a = (int)dht20.getTemperature();
int b = (int)dht20.getHumidity();

// Reuse the same buffer because LVGL copies the text into each label.
snprintf(DHT_buffer, sizeof(DHT_buffer), "%d", a);
lv_label_set_text(ui_Label1, DHT_buffer);

snprintf(DHT_buffer, sizeof(DHT_buffer), "%d", b);
lv_label_set_text(ui_Label2, DHT_buffer);

When and what it does: This code runs repeatedly in loop(). The DHT20 library reads the temperature and relative humidity, snprintf() converts the integers to strings, and the two results are then written to ui_Label1 and ui_Label2 respectively.

Key parameters: DHT_buffer[12] is large enough to hold the integer text and the string terminator for this experiment; "%d" denotes a decimal integer; the two label names must match the object names exported from SquareLine. Wire.begin(22, 21)has been specified insetup()` with SDA assigned to GPIO22 and SCL assigned to GPIO21.

Expected behavior and effect of modifications: When the sensor is functioning normally, the screen displays integer values for temperature and humidity at the corresponding positions. After commenting out the label-update code, the background and buttons remain visible, but the labels stay at their design-time default numbers; after swapping the two label objects, the display positions of temperature and humidity will be exchanged.

Key points for troubleshooting: When values do not update, check the DHT20 power supply, the GPIO22/GPIO21 wiring, and lib/DHT20/; when the compiler reports that a label cannot be found, check ui.h, ui_Screen1.c, and the object names in SquareLine.

6.7 Button Events and GPIO25

// SquareLine calls this handler when the ON button receives an event.
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;
    }
}

// The OFF button uses a separate handler and clears the shared state.
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;
    }
}
// loop() converts the UI state into the physical GPIO25 output.
if(led == 1) {
    digitalWrite(25, HIGH);
}

if(led == 0) {
    digitalWrite(25, LOW);
}

When it runs and what it does: The two event checks are located in ui_Screen1.c; after LVGL detects a button click, it modifies the global variable led; loop() then reads this variable and sets GPIO25. This way, UI events only record the target state, while hardware control is centralized in the main loop.

Key parameters: LV_EVENT_CLICKED represents a single, complete click event; led = 1 corresponds to a high level on GPIO25, and led = 0 corresponds to a low level. setup() has already configured this pin as an output via pinMode(25, OUTPUT), and the LED is turned off by default at startup.

Expected behavior and effect of modifications: After clicking ON, the LED lights up; after clicking OFF, the LED turns off. Swapping the two assignments will cause the button functions to be reversed; removing the LV_EVENT_CLICKED check may allow other events to modify the state as well; after removing the digitalWrite() call from the main loop, the on-screen button still provides visual feedback, but the LED no longer changes.

Key points for troubleshooting: When a button has no visual response, check the touch callback; when a button responds but the LED does not change, check extern int led, the event function, the GPIO25 wiring, and the LED's active level. You can also observe the led value output over the serial port to distinguish between UI event issues and hardware output issues.

7. UI Asset Creation and Integration

This section uses SquareLine Studio 1.6.1 to demonstrate the UI creation process again. The course already provides the exported UI files; beginners can first read this section to understand the workflow, then directly use the files in the project 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, and select the Arduino with TFT_eSPI template for the Arduino platform. This template generates the UI file framework suitable for Arduino and TFT_eSPI projects.

Note: When the Arduino framework is selected, 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; when switching to other hardware, you need to modify the display code according to the actual library.

19

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

20

  • Note: A 16 bit color depth can represent 65,536 colors using an RGB 5:6:5 pixel format. 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 the Inspector on the right shows the Screen properties. All subsequent widgets are 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 importing, 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's screen size; a single image should not exceed 100 KB, and preferably kept 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. In Bg Image, select background. The canvas should immediately display the course background image, and the background should fully cover the 320×240 page.

24

  1. In the Widgets panel on the left, click Label to add Label1 to Screen1. This label will be updated by the program to show the temperature value.

25

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

26

  1. In the STYLE (MAIN) of Label1, 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 choose Duplicate to create Label2, then move it to the humidity display area. Keep the names Label1 and Label2, because the main program updates them by name.

    28

  2. Select the two labels one by one, and enter default numbers in Text for easy previewing. These are only design-time placeholder values; after the board runs, they will be replaced by DHT20 readings.

    29

  3. In Widgets, click Button 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 STYLE (MAIN) > Background of Button1 and select on.png as the background image. The ON icon should appear on the canvas.

    32

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

    33

    34

  7. In the STATE settings of both buttons, check the DEFAULT and PRESSED states. The pressed state can use a noticeable color change so that, during preview, you can tell whether a button is being pressed. In "Inspector" -> "Style Settings" -> "State", set the displayed background color to white, while in the "checked" state it shows red. Set the same parameters for the "OFF" button.

    35

    36

  8. Select Button1 and click ADD EVENT

    37

  9. Select "CLICKED" as the trigger condition, and choose the trigger event in "Action". The generated program will later be modified to implement the LED control function.

    38

    Note: Since the buttons ultimately need 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 configure 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, fill in and confirm that the LVGL Include Path is lvgl.h, then click "APPLY CHANGES" after confirming.

    image-20260723170101760

    45

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

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

    46

    47

  16. Add the UI files to the PlatformIO project. We need to add the UI files from SquareLine Studio to the PlatformIO project. The .c files in the user interface should be placed in the /scr folder of the project files, while the .h files should be placed in the /include folder.

    image-20260723162715905

    PIO-34

    PIO-35

8. Observed Behavior

After the firmware is uploaded and the board is reset, the LCD is first cleared to black, and about 300 ms later the backlight turns on and loads the 320×240 UI. The background image should be displayed in full, two white integers appear slightly left of center on the screen, and the ON and OFF icons appear on the right. When the DHT20 is connected properly, the two integers update according to the temperature and humidity readings, respectively.

16

ON -> LED lights up

17

OFF -> LED turns off

18

9. Code Download