Skip to content

Display_Touch_LVGL: 7.0-inch ESP32-S3 Environment Monitoring and Touch Output Control

1. Course Introduction

This lesson uses the Arduino framework, LovyanGFX, LVGL 9.1.0, the GT911 touch controller, and the DHT20 temperature and humidity sensor to drive a 7.0-inch 800×480 HMI. After the program starts, the PCA9557 completes the display-related reset, the RGB screen backlight turns on, and the interface exported from SquareLine Studio loads. Temperature and humidity are updated once per second. Tapping the ON/OFF image button toggles the output state of GPIO38, while the serial port outputs PSRAM, display, touch, and coordinate information at the same time.

Learners need to complete board setup, project compilation, and upload. The subsequent Example1 through Example7 are standalone cases used to verify the LED, I2S audio, SD card, touch, BLE, Wi-Fi, and GPS respectively.

Reference materials:

2. Learning Objectives

  • Be able to configure the ESP32-S3, OPI PSRAM, and Arduino project dependencies, and complete compilation and upload.
  • Be able to explain the relationship between RGB display timing, double frame buffering, the LVGL flush callback, and the GT911 input callback.
  • Be able to observe DHT20 value refreshes and verify the linkage between ON/OFF touch events and the GPIO38 output.
  • Be able to locate the fault level based on the serial initialization log, on-screen display, touch coordinates, and peripheral responses.

3. Preparation

  • One CrowPanel ESP32 7.0-inch HMI (ESP32-S3, 800×480).
  • One USB data cable that supports both power delivery and data transfer.
  • One DHT20 temperature and humidity module, connected to GPIO19 (SDA) and GPIO20 (SCL).
  • One LED module, connected to GPIO38.
  • Arduino-ESP32 3.3.8, with the board set to ESP32-S3 Dev Module.
  • Keep the dependencies in libraries intact, including lvgl, LovyanGFX, PCA9557, Crowbits_DHT20, gt911-arduino-main, and others.
  • Keep the ui*.c, ui*.h, touch.h, and three image array files from the main lesson intact.
  • Enable OPI PSRAM in the ESP32-S3 tool options of the Arduino IDE; otherwise the 800×480 double frame buffer allocation may fail.

4. Software Operation Steps

This tutorial uses Arduino IDE 2.3.10 for demonstration. The IDE version is not strictly restricted; other versions work as well.

For the first-time operation, please complete the steps in order. Do not jump directly to uploading after a compilation failure.

  1. Launch the Arduino IDE, open Help > About Arduino IDE, and confirm the current IDE version. Close the "About" window before continuing.

Confirm the Arduino IDE version

  1. Enter the project folder LVGL_Arduino7.0 and open LVGL_Arduino7.0.ino. The window title should display the project name.

image-20260724105543078

image-20260724110035250

  1. Check the file tabs above the editor area and confirm that UI files such as ui.c, ui.h, ui_Screen1.c, ui_events.c, and ui_helpers.c are visible within the same project. If these files are missing, do not continue compiling; instead, copy the complete project directory again.

image-20260724110231095

  1. Open the Board Manager, search for esp32. Locate esp32 by Espressif Systems, select it, and install 3.3.8. If the interface already shows 3.3.8 installed, simply close the Board Manager.

image-20260722175540199

  1. Open File > Preferences and note the "Sketchbook location". Close the Arduino IDE, then confirm that the libraries folder exists under that directory.

How to add the library files: https://www.elecrow.com/wiki/Arduino_IDE_Library_Import_Guide.html.

Note: The course dependency libraries need to be placed in the libraries directory under the Arduino sketchbook folder. After copying, restart the Arduino IDE to prevent it from using the old library index.

Confirm the Arduino libraries directory

  1. Connect the 7.0-inch HMI board using a USB data cable, and confirm that the computer can recognize the new serial device.

IMG_7942

  1. Open Tools > Board > esp32 and select ESP32S3 Dev Module. After that, the top or status bar of the IDE must show ESP32S3 Dev Module.

Select ESP32S3 Dev Module

  1. Open Tools > Port and select the COM port that appears after connecting the board. If no port appears, first replace the USB cable with one that supports data transfer, then check the serial port status in the system Device Manager.

Select the serial port

  1. In the Tools menu, set Flash Mode: QIO 80MHz, Flash Size: 4MB (32Mb), Partition Scheme: Huge APP (3MB No OTA/1MB SPIFFS), PSRAM: OPI PSRAM, and leave the other options at the default values used during project verification.

Set the board parameters

  1. First click Verify to complete compilation verification. After verification succeeds, click Upload and wait for the write progress to reach 100%. If it stays at Connecting... for a long time, hold down the BOOT button on the board, release it once writing begins, and then recheck the port and upload mode.

    image-20260723193024887

    image-20260723193301094

  2. Click Serial Monitor in the upper right corner and set the baud rate to 115200. After reset, you should see, in order, LVGL_Arduino7.0 starting..., PSRAM, PCA9557, lcd.begin() OK, touch initialization, and Setup done; touching the screen should also output coordinate information.

    Wait for the upload to finish

5. Hardware Operation Steps

  1. With the power off, connect the DHT20 module to the board's IIC interface, and connect the LED module to the interface labeled GPIO_D.

IMG_7944

  1. Reconnect the USB cable that supports data transfer. After the upload completes and the board auto-resets, the screen backlight should turn on, and the interface should display temperature, humidity, and two buttons labeled ON and OFF.

16

  1. Tap ON with your finger and observe the interface response and the GPIO38 output state; do not touch the screen with sharp or conductive objects.

IMG_7937

  1. Tap OFF and confirm that the output returns to a low level and the LED turns off.

IMG_7938

6. Key Code Explanation

6.1 RGB Bus and 800×480 Panel

/*---------------------------------------------------------------
 * RGB display hardware description
 * Bind the board's parallel signals and timing to LovyanGFX.
 *--------------------------------------------------------------*/

/**
 * @brief Configure the RGB bus and its 800 by 480 panel.
 *
 * The data-pin order and synchronization timing must match the physical
 * panel so the display receives correctly aligned RGB565 frames.
 */
const int8_t dataPins[16] = {15, 7, 6, 5, 4, 9, 46, 3, 8, 16, 1, 14, 21, 47, 48, 45};
memcpy(busConfig.pin_data, dataPins, sizeof(dataPins));
busConfig.pin_henable = 41;
busConfig.pin_vsync = 40;
busConfig.pin_hsync = 39;
busConfig.pin_pclk = 0;
busConfig.freq_write = 24000000;

This configuration is executed during the global lcd construction and determines the RGB data lines, synchronization signals, and pixel clock. It must match the board schematic and panel timing. Incorrect configuration can cause a black screen, color distortion, image scrolling, or tearing. When troubleshooting, first confirm the board version, then verify the pin and porch parameters; do not replace them with parameters from a screen of a different size.

6.2 LVGL Double Frame Buffering and Flush Callback

// LovyanGFX owns two full-screen RGB buffers after lcd.begin() succeeds.
lv_color_t* frameBuffer0 = (lv_color_t*)lcd._bus_instance.getFrameBuffer(0);
lv_color_t* frameBuffer1 = (lv_color_t*)lcd._bus_instance.getFrameBuffer(1);

// LVGL renders directly into the LovyanGFX buffers and asks the flush
// callback to present the completed frame at the next VSYNC boundary.
lv_display_t *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, frameBuffer0, frameBuffer1,
                       screenWidth * screenHeight * sizeof(lv_color_t),
                       LV_DISPLAY_RENDER_MODE_FULL);

LVGL uses the two full-screen buffers provided by LovyanGFX directly. my_disp_flush() switches the buffer at VSYNC when a frame is complete, thereby reducing tearing. If PSRAM is not enabled, the buffer address may be null or lcd.begin() may fail. If lv_display_flush_ready() is omitted, the interface may only refresh once.

6.3 GT911 Input Callback

/*---------------------------------------------------------------
 * LVGL touch bridge
 * Convert the board touch driver's state into LVGL pointer samples.
 *--------------------------------------------------------------*/

/**
 * @brief Supply the current touch state and position to LVGL.
 *
 * A press includes calibrated coordinates. Paths without an active touch
 * explicitly report release so LVGL cannot retain a stale pressed state.
 */
void my_touchpad_read(lv_indev_t *indev, lv_indev_data_t *data)
{
  if (touch_has_signal())
  {
    if (touch_touched())
    {
      data->state = LV_INDEV_STATE_PR;
      data->point.x = touch_last_x;
      data->point.y = touch_last_y;
    }
    else if (touch_released())
    {
      data->state = LV_INDEV_STATE_REL;
    }
  }
  else
  {
    data->state = LV_INDEV_STATE_REL;
  }
}

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

LVGL periodically calls my_touchpad_read(). The touch driver maps the GT911 coordinates onto the 800×480 display area. When the interface appears normal but the buttons do not respond, first check whether the serial port outputs coordinates, then inspect the SDA/SCL lines, touch orientation, and callback registration.

6.4 Temperature/Humidity and Output Linkage

/*---------------------------------------------------------------
 * Run the dashboard
 * Sample slowly changing sensor data while servicing LVGL continuously.
 *--------------------------------------------------------------*/

// Unsigned elapsed-time comparison remains valid across millis() rollover.
if (now - lastSensorRead >= 1000) {
  int temperature = (int)dht20.getTemperature();
  int humidity = (int)dht20.getHumidity();
  lv_label_set_text(ui_TempLabel, DHT_buffer);
  lv_label_set_text(ui_HumiLabel, DHT_buffer);
}

// The two independent checks preserve the command selected by the UI.
if (led == 1) digitalWrite(38, HIGH);
if (led == 0) digitalWrite(38, LOW);

The sensor is read once per second, and the labels are updated only when the integer values change, avoiding meaningless redraws. The SquareLine event code modifies the global led, and the main loop then writes to GPIO38. If the button appearance changes but the peripheral does not act, check whether the event file modifies led, then measure the GPIO38 voltage level.

7. UI Asset Creation and Integration

  1. 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 compilation. 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. Open SquareLine Studio 1.6.1, click Create at the top to create a new project, select 9.1 as the LVGL major version, and choose Elecrow as the vendor category. In the template list, select DIS08070H - ESP32 7inch HMI Display 800x480 RGB - Arduino-IDE. This template is designed for the Elecrow CrowPanel 7-inch capacitive touch screen, with preset parameters of 800×480 resolution and LVGL 9.1. Complete the project creation.

Note: This template is an Arduino project template exclusively for the Elecrow CrowPanel. It directly generates LVGL UI code that matches the hardware. If you switch to a different screen model, you must select the corresponding hardware template and verify the resolution and color depth settings.

image-20260724111759549

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

image-20260724111904548

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.

  1. 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.

Confirm the Screen1 canvas

  1. In the Assets area, click ADD FILE TO ASSETS and import LVGL-Assets-800x480/background.png, on.png, and off.png in sequence. After importing, you should see three thumbnails.

Import image assets

Note: Image assets support the PNG format only. The pixel dimensions of an image should be smaller than the project's screen size. A single image should not exceed 100 KB, and ideally should be 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.

Enable the background image setting

  1. In Bg Image, select background. The canvas should immediately display the course background image, and the background should fully cover the entire page.

Set the background image

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

Add the temperature label

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

Adjust the temperature label position

  1. In the label'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 not overflow the background frame.

Set the label text style

  1. Right-click Label1 and choose Duplicate to generate Label2, then move it to the humidity display area. Keep the names Label1 and Label2, because the main program updates them by name. Copy label

  2. Select the two Labels individually, and in Text, enter default numbers that are convenient for previewing. These are only design-time placeholder values; once the development board runs, they will be replaced by the DHT20 readings.

    Fill in label default values

  3. In Widgets, click Button to add the first button, and drag it to the ON area on the right side of the interface.

    Add the ON button

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

    Adjust the ON button size and position

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

    Set the ON button image

  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 so they can be matched to the exported event functions.

    Duplicate the button

    Set the OFF button image

  7. In the STATE settings of both buttons, check the DEFAULT and PRESSED states. For the pressed state, you may use a noticeable color change so that it is clear during preview whether a button is pressed. In "Inspector" → "Style Settings" → "State", set the displayed background color to white, and when in the "Fixed State", display red. Apply the same parameters to the "OFF" button.

    Check the button default state

    Set the button pressed state

  8. Select the ON button and click ADD EVENT.

    Apply button state settings

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

    38

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

  10. Complete this event. In the example, you can first choose to switch to the Screen1 screen, which generates the event structure.

    Set the event trigger condition

  11. Add an event to the OFF button in the same way.

    Complete the ON button event

  12. Click Run to preview the display effect and button states on the interface.

    Add an event to the OFF button

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

    Run preview

    image-20260724113200377

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

    image-20260724113251380

    image-20260724113402379

    Tip: After selecting Flat export, all output files are placed in the same folder, so the program does not need path modifications. If Flat export is not selected, files are scattered across different folders and the compiler may fail to 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, ui.c, ui.h, ui_Screen1.c, the event file, the helper file, and the image array file should appear in the target directory.

    Export UI files

    image-20260724113447185

  16. Close the Arduino IDE, and copy all exported .c and .h files to the directory containing LVGL_Arduino7.0.ino.

    image-20260724113632639

  17. Reopen the project. In the event file, keep the event type check, and make the ON event execute led = 1; and the OFF event execute led = 0;. Then return to the main program and click Verify to confirm there are no errors about led or UI objects not being found.

    image-20260724113730663

    image-20260724113804327

The ui_Screen1.c in the project ultimately creates the background image, two white value labels, and two image buttons. If you modify a widget name or resource name in SquareLine Studio, you must synchronously check the ui.h declarations, the ui_Screen1.c references, the ui_events.c event function, and ui_TempLabel and ui_HumiLabel in the main program.

8. Experimental Results

After power-on, the backlight turns on, and the screen displays the background image, the temperature/humidity values, and the ON/OFF buttons.

16

After clicking ON, the LED lights up.

IMG_7945

After clicking OFF, it turns off.

IMG_7946

9. Code Download

Arduino project: Arduino_7.0.


Example Demo of ESP32 HMI Function

Example1:LED_blinking

Connect the LED to the GPIO_D (IO38) port, then flash the following code to the chip. The LED will then start blinking.

IMG_7947

/*---------------------------------------------------------------
 * LED blinking lesson
 * Drive the board LED with a fixed on/off timing pattern.
 *--------------------------------------------------------------*/

// GPIO connected to the controllable LED.
#define D_PIN 38


/*---------------------------------------------------------------
 * Initialize the lesson
 * Prepare serial diagnostics and configure the LED output.
 *--------------------------------------------------------------*/

/**
 * @brief Prepare the hardware used by the blinking example.
 *
 * The pin must be configured as an output before the program can
 * apply HIGH and LOW voltage levels to the LED circuit.
 *
 * @param None.
 * @return Nothing.
 * @note Called once by the Arduino framework after reset.
 */
void setup() {
  Serial.begin(115200);
  pinMode(D_PIN, OUTPUT);
}


/*---------------------------------------------------------------
 * Generate the blinking pattern
 * Keep the LED on and off for equal half-second intervals.
 *--------------------------------------------------------------*/

/**
 * @brief Repeat one complete LED blinking cycle.
 *
 * The two delays make the HIGH and LOW phases visible and produce
 * a one-second period with a 50 percent duty cycle.
 *
 * @param None.
 * @return Nothing.
 * @note Called repeatedly by the Arduino framework after setup().
 */
void loop() {
  digitalWrite(D_PIN, HIGH);
  delay(500);
  digitalWrite(D_PIN, LOW);
  delay(500);
}

GPIO38-LED

Example2:Play_music

Connect the speaker to the SPK port. Check whether the ESP32-audioI2S library is installed (copy the ESP32-audioI2S folder from the downloaded library files to the …/arduino/libraries directory). Upload the following code to the chip.

/*---------------------------------------------------------------
 * I2S melody lesson
 * Synthesize a sine wave and send a melody to an I2S audio device.
 *--------------------------------------------------------------*/

#include <driver/i2s.h>
#include <math.h>


/*---------------------------------------------------------------
 * Audio hardware and signal settings
 * Match the ESP32 I2S signals to the audio circuit on the board.
 *--------------------------------------------------------------*/

// GPIO connections for I2S data, bit clock, and word-select clock.
#define I2S_DOUT 17
#define I2S_BCLK 42
#define I2S_LRC  18

// Audio samples generated per second.
const int sampleRate = 44100;

// Peak signed 16-bit value, used here for full-scale output.
const int16_t AMPLITUDE = 32767;


/*---------------------------------------------------------------
 * Musical note definitions
 * Associate each note name with its fundamental frequency in hertz.
 *--------------------------------------------------------------*/

#define NOTE_C4 262
#define NOTE_D4 294
#define NOTE_E4 330
#define NOTE_F4 349
#define NOTE_G4 392
#define NOTE_A4 440
#define NOTE_B4 494
#define NOTE_C5 523
#define NOTE_D5 587
#define NOTE_E5 659
#define NOTE_F5 698
#define NOTE_G5 784

// Describes one note as a pitch and its playback time in milliseconds.
struct Note {
  int freq;
  int durationMs;
};

// Stores the four phrases of "Happy Birthday" in playback order.
Note melody[] = {
  // First phrase: "Happy birthday to you."
  {NOTE_G4, 200}, {NOTE_G4, 200}, {NOTE_A4, 400}, {NOTE_G4, 400}, {NOTE_C5, 400}, {NOTE_B4, 800},

  // Second phrase: "Happy birthday to you."
  {NOTE_G4, 200}, {NOTE_G4, 200}, {NOTE_A4, 400}, {NOTE_G4, 400}, {NOTE_D5, 400}, {NOTE_C5, 800},

  // Third phrase: "Happy birthday dear [name]."
  {NOTE_G4, 200}, {NOTE_G4, 200}, {NOTE_G5, 400}, {NOTE_E5, 400}, {NOTE_C5, 400}, {NOTE_B4, 200}, {NOTE_A4, 600},

  // Fourth phrase: "Happy birthday to you."
  {NOTE_F5, 200}, {NOTE_F5, 200}, {NOTE_E5, 400}, {NOTE_C5, 400}, {NOTE_D5, 400}, {NOTE_C5, 800},

  // A zero duration marks the end without storing a separate note count.
  {0, 0}
};


/*---------------------------------------------------------------
 * Initialize I2S audio output
 * Configure the peripheral, assign its pins, and play the first melody.
 *--------------------------------------------------------------*/

/**
 * @brief Prepare the serial port and I2S transmitter.
 *
 * Stereo 16-bit frames are selected because playTone() writes the
 * same synthesized sample to both left and right channels.
 *
 * @param None.
 * @return Nothing.
 * @note Called once by the Arduino framework after reset.
 */
void setup() {
  Serial.begin(115200);
  Serial.println("Happy Birthday I2S Test - Full Volume");

  i2s_config_t i2s_config = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX),
    .sample_rate = sampleRate,
    .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
    .channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 64,
    .use_apll = false,
    .tx_desc_auto_clear = true,
    .fixed_mclk = 0
  };

  i2s_pin_config_t pin_config = {
    .bck_io_num = I2S_BCLK,
    .ws_io_num = I2S_LRC,
    .data_out_num = I2S_DOUT,
    .data_in_num = I2S_PIN_NO_CHANGE
  };

  i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
  i2s_set_pin(I2S_NUM_0, &pin_config);
  i2s_set_clk(I2S_NUM_0, sampleRate, I2S_BITS_PER_SAMPLE_16BIT, I2S_CHANNEL_STEREO);

  playMelody();
}


/*---------------------------------------------------------------
 * Synthesize one tone
 * Generate stereo PCM samples in small batches for the I2S driver.
 *--------------------------------------------------------------*/

/**
 * @brief Play one tone or a timed period of silence.
 *
 * A zero frequency represents a rest. Other frequencies advance a
 * sine-wave phase once per sample so pitch remains independent of
 * the batch size used for I2S transfers.
 *
 * @param freq Tone frequency in hertz, or zero for silence.
 * @param durationMs Playback duration in milliseconds.
 * @return Nothing.
 * @note Called by playMelody() for notes and inter-note pauses.
 */
void playTone(int freq, int durationMs) {
  if (freq == 0) {
    int samplesCount = sampleRate * durationMs / 1000;
    int16_t silence[128] = {0};
    size_t bytes_written;

    // Each frame has two samples, one for each stereo channel.
    for (int i = 0; i < samplesCount; i += 64) {
      int batch = min(64, samplesCount - i);
      i2s_write(I2S_NUM_0, silence, batch * sizeof(int16_t) * 2, &bytes_written, portMAX_DELAY);
    }
    return;
  }

  int samplesCount = sampleRate * durationMs / 1000;
  float phase = 0;
  float phaseIncrement = 2.
0 * PI * freq / sampleRate;
  size_t bytes_written;

  // Small batches limit stack use while keeping the I2S stream continuous.
  for (int i = 0; i < samplesCount; i += 64) {
    int16_t samples[128];
    int batch = min(64, samplesCount - i);

    for (int j = 0; j < batch; j++) {
      int16_t sample = (int16_t)(sin(phase) * AMPLITUDE);
      samples[j * 2] = sample;
      samples[j * 2 + 1] = sample;
      phase += phaseIncrement;

      // Wrapping the phase prevents its value from growing indefinitely.
      if (phase > 2.0 * PI) phase -= 2.0 * PI;
    }

    i2s_write(I2S_NUM_0, samples, batch * sizeof(int16_t) * 2, &bytes_written, portMAX_DELAY);
  }
}


/*---------------------------------------------------------------
 * Play the stored melody
 * Visit each note until the zero-duration end marker is reached.
 *--------------------------------------------------------------*/

/**
 * @brief Play every note in the melody table.
 *
 * A short silent interval separates adjacent notes so repeated pitches
 * remain perceptible as distinct musical events.
 *
 * @param None.
 * @return Nothing.
 * @note Called during setup() and every pass through loop().
 */
void playMelody() {
  int i = 0;
  while (melody[i].durationMs > 0) {
    playTone(melody[i].freq, melody[i].durationMs);
    playTone(0, 50);
    i++;
  }
}


/*---------------------------------------------------------------
 * Repeat playback
 * Leave a pause before restarting the complete melody.
 *--------------------------------------------------------------*/

/**
 * @brief Replay the melody at regular intervals.
 *
 * @param None.
 * @return Nothing.
 * @note Called repeatedly by the Arduino framework after setup().
 */
void loop() {
  delay(2000);
  playMelody();
}

IMG_7949(1)

Example3: Initialize SD Card slot

Insert an SD card formatted as FAT16 or FAT32. Cards using other file systems may not be recognized.

IMG_7950

/*---------------------------------------------------------------
 * SD card lesson
 * Mount a card through SPI and display its directory contents.
 *--------------------------------------------------------------*/

#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <FS.h>


/*---------------------------------------------------------------
 * SD card hardware connections
 * Assign the SPI signals used by the 7.0-inch display board.
 *--------------------------------------------------------------*/

#define SD_MOSI 11
#define SD_MISO 13
#define SD_SCK  12
#define SD_CS   10


/*---------------------------------------------------------------
 * Initialize the card
 * Start SPI, mount the file system, and report the result.
 *--------------------------------------------------------------*/

/**
 * @brief Prepare serial diagnostics and test the SD card.
 *
 * The short delay gives the SPI bus and card power time to settle
 * before the first mount attempt.
 *
 * @param None.
 * @return Nothing.
 * @note Called once by the Arduino framework after reset.
 */
void setup() {
  Serial.begin(115200);
  SPI.begin(SD_SCK, SD_MISO, SD_MOSI);
  delay(100);

  if (SD_init() == 1) {
    Serial.println("Card Mount Failed");
  }
  else
    Serial.println("initialize SD Card successfully");
}

/**
 * @brief Keep the completed card demonstration idle.
 *
 * @param None.
 * @return Nothing.
 * @note Called repeatedly after setup(); no repeated work is required.
 */
void loop() {
}


/*---------------------------------------------------------------
 * Validate and inspect the SD card
 * Confirm that media is present before reading its capacity and files.
 *--------------------------------------------------------------*/

/**
 * @brief Mount the SD card and print basic information.
 *
 * Directory traversal begins only after both the mount operation and
 * media-type check succeed, preventing invalid file-system access.
 *
 * @param None.
 * @return 0 when initialization succeeds.
 * @return 1 when the card cannot be mounted or no card is detected.
 * @note Called once from setup() after the SPI bus is ready.
 */
int SD_init() {
  if (!SD.begin(SD_CS)) {
    Serial.println("Card Mount Failed");
    return 1;
  }

  uint8_t cardType = SD.cardType();
  if (cardType == CARD_NONE) {
    Serial.println("No TF card attached");
    return 1;
  }

  uint64_t cardSize = SD.cardSize() / (1024 * 1024);
  Serial.printf("TF Card Size: %lluMB\n", cardSize);
  listDir(SD, "/", 2);
  return 0;
}


/*---------------------------------------------------------------
 * Traverse the directory tree
 * Print files and recursively visit folders to a controlled depth.
 *--------------------------------------------------------------*/

/**
 * @brief List one directory and optionally visit its subdirectories.
 *
 * The levels value is reduced at each recursive call. This bounds the
 * traversal depth and prevents the demonstration from descending through
 * an unexpectedly large directory hierarchy.
 *
 * @param fs File-system object that owns the requested directory.
 * @param dirname Path of the directory to open.
 * @param levels Remaining number of subdirectory levels to visit.
 * @return Nothing.
 * @note Called by SD_init() for the root and recursively for folders.
 */
void listDir(fs::FS & fs, const char *dirname, uint8_t levels) {
  File root = fs.open(dirname);
  if (!root) {
    return;
  }

  if (!root.isDirectory()) {
    Serial.println("Not a directory");
    return;
  }

  File file = root.openNextFile();
  while (file) {
    if (file.isDirectory()) {
      Serial.print("  DIR : ");
      Serial.println(file.name());

      if (levels) {
        listDir(fs, file.name(), levels - 1);
      }
    }
    else {
      Serial.print("FILE: ");
      Serial.print(file.name());
      Serial.print("SIZE: ");
      Serial.println(file.size());
    }

    file = root.openNextFile();
  }
}

Observation: The serial monitor displays the SD card capacity and lists the file names and file sizes on the card in sequence.

55

Example4: Initialize the touch

/*---------------------------------------------------------------
 * Touch input lesson
 * Read calibrated screen coordinates from the touch controller.
 *--------------------------------------------------------------*/

#include "touch.h"


/*---------------------------------------------------------------
 * Initialize touch input
 * Start diagnostics before configuring the selected controller.
 *--------------------------------------------------------------*/

/**
 * @brief Prepare serial output and the touch controller.
 *
 * @param None.
 * @return Nothing.
 * @note Called once by the Arduino framework after reset.
 */
void setup() {
  Serial.begin(115200);
  touch_init();
}


/*---------------------------------------------------------------
 * Report touch coordinates
 * Print a position only when the controller reports an active touch.
 *--------------------------------------------------------------*/

/**
 * @brief Poll the touch controller and display valid coordinates.
 *
 * Separating signal availability from touch state allows the same lesson
 * to work with controller drivers that expose different interrupt models.
 *
 * @param None.
 * @return Nothing.
 * @note Called repeatedly by the Arduino framework after setup().
 */
void loop() {
  if (touch_has_signal()) {
    if (touch_touched()) {
      Serial.print("Data x :");
      Serial.println(touch_last_x);
      Serial.print("Data y :");
      Serial.println(touch_last_y);
    }
  }
}

56

Example5: Initialize the interact communication of Bluetooth

Upload the following code to the board, then use your phone to search for Bluetooth devices.

/*---------------------------------------------------------------
 * Bluetooth Low Energy server lesson
 * Advertise one service with a readable, writable, notifiable value.
 *--------------------------------------------------------------*/

#include "BLEDevice.h"
#include "BLEServer.h"
#include "BLEUtils.h"
#include "BLE2902.h"
#include <BLECharacteristic.h>


/*---------------------------------------------------------------
 * BLE object references
 * Retain access to the objects created during server initialization.
 *--------------------------------------------------------------*/

BLEAdvertising* pAdvertising = NULL;     // Controls packets that make the server discoverable.
BLEServer* pServer = NULL;               // Represents the local BLE server.
BLEService *pService = NULL;             // Groups the lesson characteristic under one service.
BLECharacteristic* pCharacteristic = NULL; // Stores the value exposed to BLE clients.

// Human-readable device name shown during BLE discovery.
#define bleServerName "ESP32SPI-BLE"

// Stable identifiers that allow a client to locate the lesson service and value.
#define SERVICE_UUID "6479571c-2e6d-4b34-abe9-c35116712345"
#define CHARACTERISTIC_UUID "826f072d-f87c-4ae6-a416-6ffdcaa02d73"

// Records whether a remote client currently has an active connection.
bool connected_state = false;


/*---------------------------------------------------------------
 * Track the connection state
 * Let the BLE stack update application state through callbacks.
 *--------------------------------------------------------------*/

class MyServerCallbacks: public BLEServerCallbacks {
  /**
   * @brief Record that a BLE client has connected.
   *
   * @param pServer Server that accepted the connection.
   * @return Nothing.
   * @note Called automatically by the BLE stack on connection.
   */
  void onConnect(BLEServer *pServer) {
    connected_state = true;
  }

  /**
   * @brief Record that the current BLE client has disconnected.
   *
   * @param pServer Server whose connection ended.
   * @return Nothing.
   * @note Called automatically by the BLE stack on disconnection.
   */
  void onDisconnect(BLEServer *pServer) {
    connected_state = false;
  }
};


/*---------------------------------------------------------------
 * Build and advertise the BLE service
 * Create the server hierarchy before making it visible to clients.
 *--------------------------------------------------------------*/

/**
 * @brief Initialize the BLE server and begin advertising.
 *
 * The characteristic supports read, write, and notify operations so the
 * same value can demonstrate the three common client interaction patterns.
 *
 * @param None.
 * @return Nothing.
 * @note Called once by the Arduino framework after reset.
 */
void setup() {
  Serial.begin(115200);

  BLEDevice::init(bleServerName);
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new MyServerCallbacks());
  pService = pServer->createService(SERVICE_UUID);

  pCharacteristic = pService->createCharacteristic(  // CHARACTERISTIC_UUID,
                      BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY);
  pCharacteristic->setValue("ELECROW");

  pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(SERVICE_UUID);
  pAdvertising->start();
  pService->start();
}

/**
 * @brief Leave BLE communication to the event-driven stack.
 *
 * @param None.
 * @return Nothing.
 * @note Called repeatedly after setup(); callbacks handle connection events.
 */
void loop() {
}

BLE

Example6:Initialize the WIFI

Upload the following code to the ESP display. Note: replace the Wi-Fi network name and password with your actual values.

/*---------------------------------------------------------------
 * Wi-Fi station lesson
 * Join an access point and report the assigned network address.
 *--------------------------------------------------------------*/

#include <WiFi.h>

// Credentials used by the ESP32 when joining the wireless network.
const char *ssid = "yanfa1";
const char *password = "1223334444yanfa";


/*---------------------------------------------------------------
 * Connect to Wi-Fi
 * Wait for a complete station connection before using network details.
 *--------------------------------------------------------------*/

/**
 * @brief Connect the board to the configured wireless network.
 *
 * Automatic reconnection allows the Wi-Fi stack to recover after a brief
 * loss of coverage without requiring application-level retry logic.
 *
 * @param None.
 * @return Nothing.
 * @note Called once by the Arduino framework after reset.
 */
void setup() {
  Serial.begin(115200);

  WiFi.begin(ssid, password);
  WiFi.setAutoReconnect(true);

  // Network information is valid only after the station has associated.
  while (WiFi.status() != WL_CONNECTED) {
    delay(100);
    Serial.println("connecting");
  }

  Serial.println("WiFi is connected.");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
}

/**
 * @brief Keep the completed connection demonstration idle.
 *
 * @param None.
 * @return Nothing.
 * @note Called repeatedly after setup(); Wi-Fi maintenance runs internally.
 */
void loop() {
}

Example7:Connect Crowtail-GPS module via UART to Get Location

Because the USB-to-serial interface shares the same pins as the UART interface, the USB function and the UART function cannot be used at the same time. When flashing or uploading firmware, make sure to disconnect the UART interface.

img

After the program has been uploaded successfully, connect the GPS module to the board and power the board back on through the USB interface.

img

Note: For best results, choose a location with good weather and an open outdoor environment. This allows the GPS module to receive satellite signals normally, collect data, and display it on the screen.

img

GPS module purchase link: https://www.elecrow.com/crowtailgps-p-1515.html.

/*---------------------------------------------------------------
 * GPS display lesson
 * Parse NMEA data and present live positioning information with LVGL.
 *--------------------------------------------------------------*/

#include <PCA9557.h>
#include <lvgl.h>
#include <SPI.h>
#include <LovyanGFX.hpp>
#include <lgfx/v1/platforms/esp32s3/Panel_RGB.hpp>
#include <lgfx/v1/platforms/esp32s3/Bus_RGB.hpp>

#define TFT_BL 2

/*---------------------------------------------------------------
 * GPS serial interface
 * Use the ESP32's second hardware UART for the external receiver.
 *--------------------------------------------------------------*/

#define GPS_RX 44
#define GPS_TX 43
// Receives the continuous NMEA character stream from the GPS module.
HardwareSerial gpsSerial(1);

/*---------------------------------------------------------------
 * NMEA receive and navigation state
 * Assemble one sentence at a time and retain the latest decoded values.
 *--------------------------------------------------------------*/

// Holds the NMEA sentence currently being collected from the UART.
char nmeaLine[128];
// Identifies the next free location in nmeaLine.
byte nmeaIndex = 0;

// Stores the most recent values assembled from GGA, RMC, and VTG sentences.
struct {
  bool valid = false;
  float lat = 0;
  float lon = 0;
  char latDir = 'N';
  char lonDir = 'E';
  float alt = 0;
  float speed = 0;
  uint8_t sats = 0;
  uint8_t fixType = 0;
  char timeStr[10] = "--:--:--";
  char dateStr[12] = "----/--/--";
} gps;

/*---------------------------------------------------------------
 * RGB display hardware description
 * Bind the board's parallel data pins and timing to LovyanGFX.
 *--------------------------------------------------------------*/

class LGFX : public lgfx::LGFX_Device
{
public:
  lgfx::Bus_RGB _bus_instance;
  lgfx::Panel_RGB _panel_instance;

  /**
   * @brief Configure the RGB bus and its 800 by 480 panel.
   *
   * The pin order and porch timing must match the physical panel so each
   * frame is transferred with the correct color and synchronization signals.
   *
   * @param None.
   * @return A configured LGFX display object.
   * @note Called automatically while the global lcd object is constructed.
   */
  LGFX()
  {
    auto busConfig = _bus_instance.config();
    busConfig.panel = &_panel_instance;
    const int8_t dataPins[16] = {15, 7, 6, 5, 4, 9, 46, 3, 8, 16, 1, 14, 21, 47, 48, 45};
    memcpy(busConfig.pin_data, dataPins, sizeof(dataPins));
    busConfig.pin_henable = 41;
    busConfig.pin_vsync = 40;
    busConfig.pin_hsync = 39;
    busConfig.pin_pclk = 0;
    busConfig.freq_write = 24000000;
    busConfig.hsync_polarity = 0;
    busConfig.hsync_front_porch = 40;
    busConfig.hsync_pulse_width = 48;
    busConfig.hsync_back_porch = 40;
    busConfig.vsync_polarity = 0;
    busConfig.vsync_front_porch = 1;
    busConfig.vsync_pulse_width = 31;
    busConfig.vsync_back_porch = 13;
    busConfig.pclk_active_neg = 1;
    busConfig.de_idle_high = 0;
    busConfig.pclk_idle_high = 0;
    _bus_instance.config(busConfig);

    auto panelConfig = _panel_instance.config();
    panelConfig.memory_width = 800;
    panelConfig.memory_height = 480;
    panelConfig.panel_width = 800;
    panelConfig.panel_height = 480;
    _panel_instance.config(panelConfig);
    _panel_instance.setBus(&_bus_instance);
    setPanel(&_panel_instance);
  }
};

// Owns the RGB bus, panel, and frame buffers used for drawing.
LGFX lcd;

/*---------------------------------------------------------------
 * LVGL display and input bridge
 * Connect LVGL rendering and pointer data to the board drivers.
 *--------------------------------------------------------------*/

// Logical dimensions shared by LVGL and the physical panel.
static constexpr uint32_t screenWidth = 800;
static constexpr uint32_t screenHeight = 480;
#include "touch.h"

/**
 * @brief Present a completed LVGL frame on the RGB display.
 *
 * @param display LVGL display that requested the transfer.
 * @param area Updated area supplied by LVGL; full-frame mode is used here.
 * @param pixelMap Address of the frame buffer ready for presentation.
 * @return Nothing.
 * @note Called by LVGL whenever rendering for a frame is complete.
 */
void my_disp_flush(lv_display_t *display, const lv_area_t *area, uint8_t *pixelMap)
{
  if (!lcd._bus_instance.presentFrameBuffer(pixelMap)) {
    Serial.println("LovyanGFX VSYNC frame switch timeout");
  }
  lv_display_flush_ready(display);
}

/**
 * @brief Translate controller state into an LVGL pointer sample.
 *
 * A press includes its latest calibrated position. Every path without an
 * active touch reports release so LVGL cannot retain a stale pressed state.
 *
 * @param indev LVGL input device requesting a sample.
 * @param data Destination for the pointer state and coordinates.
 * @return Nothing.
 * @note Called periodically by LVGL after registration in setup().
 */
void my_touchpad_read(lv_indev_t *indev, lv_indev_data_t *data)
{
  if (touch_has_signal())
  {
    if (touch_touched())
    {
      data->state = LV_INDEV_STATE_PR;
      data->point.x = touch_last_x;
      data->point.y = touch_last_y;
      Serial.print("Data x ");
      Serial.println(data->point.x);
      Serial.print("Data y ");
      Serial.println(data->point.y);
    }
    else if (touch_released())
    {
      data->state = LV_INDEV_STATE_REL;
    }
  }
  else
  {
    data->state = LV_INDEV_STATE_REL;
  }
  delay(15);
}


/*---------------------------------------------------------------
 * Validate NMEA sentences
 * Reject incomplete or corrupted input before modifying navigation state.
 *--------------------------------------------------------------*/

/**
 * @brief Verify the XOR checksum appended to an NMEA sentence.
 *
 * NMEA checksums cover the characters between '$' and '*'. A malformed
 * checksum suffix is rejected before its hexadecimal value is examined.
 *
 * @param line Null-terminated NMEA sentence to verify.
 * @return true when the calculated and received checksums match.
 * @return false when the sentence is malformed or corrupted.
 * @note Called by handleNMEA() for every completed input sentence.
 */
bool checkNMEA(const char* line) {
  const char* star = strchr(line, '*');
  if (!star || strlen(star) < 3) return false;
  byte calc = 0;
  for (const char* p = line + 1; *p && *p != '*'; p++) {
    calc ^= *p;
  }
  byte recv = (byte)strtol(star + 1, NULL, 16);
  return calc == recv;
}

/**
 * @brief Convert an NMEA degree-minute coordinate to decimal degrees.
 *
 * South and west coordinates become negative so the result follows the
 * conventional signed latitude and longitude representation.
 *
 * @param dm Coordinate encoded as degrees followed by decimal minutes.
 * @param dir Hemisphere letter: N, S, E, or W.
 * @return Signed coordinate in decimal degrees, or zero for invalid input.
 * @note Called while parsing valid GGA and RMC positions.
 */
float dmToDd(const char* dm, char dir) {
  if (!dm || strlen(dm) < 3) return 0;
  float val = atof(dm);
  int deg = (int)(val / 100);
  float min = val - deg * 100;
  float dd = deg + min / 60.0;
  return (dir == 'S' || dir == 'W') ? -dd : dd;
}

/*---------------------------------------------------------------
 * Decode supported NMEA sentence types
 * Extract complementary fields from GGA, RMC, and VTG messages.
 *--------------------------------------------------------------*/

/**
 * @brief Decode fix quality, satellites, altitude, time, and position.
 *
 * strtok() advances through fields in standard GGA order. Coordinates are
 * committed only when the receiver reports a valid fix quality.
 *
 * @param p Writable GGA sentence; tokenization modifies this buffer.
 * @return Nothing.
 * @note Called by handleNMEA() for GP and GN GGA sentences.
 */
void parseGGA(char* p) {
  char* tok = strtok(p, ",");
  tok = strtok(NULL, ","); // time
  if (tok && strlen(tok) >= 6) {
    snprintf(gps.timeStr, sizeof(gps.timeStr), "%c%c:%c%c:%c%c",
             tok[0], tok[1], tok[2], tok[3], tok[4], tok[5]);
  }
  tok = strtok(NULL, ","); // lat
  char* lat = tok;
  tok = strtok(NULL, ","); // N/S
  char latD = tok ? tok[0] : 'N';
  tok = strtok(NULL, ","); // lon
  char* lon = tok;
  tok = strtok(NULL, ","); // E/W
  char lonD = tok ? tok[0] : 'E';
  tok = strtok(NULL, ","); // fix
  gps.fixType = tok ? atoi(tok) : 0;
  gps.valid = (gps.fixType > 0);
  tok = strtok(NULL, ","); // sats
  gps.sats = tok ? atoi(tok) : 0;
  tok = strtok(NULL, ","); // hdop
  tok = strtok(NULL, ","); // alt
  gps.alt = (tok && strlen(tok) > 0) ? atof(tok) : 0;

  if (gps.valid) {
    gps.lat = dmToDd(lat, latD);
    gps.lon = dmToDd(lon, lonD);
    gps.latDir = latD;
    gps.lonDir = lonD;
  }
}

/**
 * @brief Decode validity, speed, date, and position from an RMC sentence.
 *
 * RMC reports speed in knots, so multiplying by 1.852 converts it to the
 * kilometres-per-hour unit displayed by the interface.
 *
 * @param p Writable RMC sentence; tokenization modifies this buffer.
 * @return Nothing.
 * @note Called by handleNMEA() for GP and GN RMC sentences.
 */
void parseRMC(char* p) {
  char* tok = strtok(p, ",");
  tok = strtok(NULL, ","); // time
  tok = strtok(NULL, ","); // status
  gps.valid = (tok && tok[0] == 'A');
  tok = strtok(NULL, ","); // lat
  char* lat = tok;
  tok = strtok(NULL, ","); // N/S
  char latD = tok ? tok[0] : 'N';
  tok = strtok(NULL, ","); // lon
  char* lon = tok;
  tok = strtok(NULL, ","); // E/W
  char lonD = tok ? tok[0] : 'E';
  tok = strtok(NULL, ","); // speed knots
  gps.speed = (tok && strlen(tok) > 0) ? atof(tok) * 1.852 : 0;
  tok = strtok(NULL, ","); // course
  tok = strtok(NULL, ","); // date
  if (tok && strlen(tok) == 6) {
    snprintf(gps.dateStr, sizeof(gps.dateStr), "20%c%c/%c%c/%c%c",
             tok[4], tok[5], tok[2], tok[3], tok[0], tok[1]);
  }

  if (gps.valid) {
    gps.lat = dmToDd(lat, latD);
    gps.lon = dmToDd(lon, lonD);
    gps.latDir = latD;
    gps.lonDir = lonD;
  }
}

/**
 * @brief Decode the kilometres-per-hour field from a VTG sentence.
 *
 * @param p Writable VTG sentence; tokenization modifies this buffer.
 * @return Nothing.
 * @note Called by handleNMEA() for GP and GN VTG sentences.
 */
void parseVTG(char* p) {
  char* tok = strtok(p, ",");
  tok = strtok(NULL, ","); // true track
  tok = strtok(NULL, ","); // T
  tok = strtok(NULL, ","); // mag track
  tok = strtok(NULL, ","); // M
  tok = strtok(NULL, ","); // speed knots
  tok = strtok(NULL, ","); // N
  tok = strtok(NULL, ","); // speed km/h
  if (tok && strlen(tok) > 0) {
    gps.speed = atof(tok);
  }
}

/**
 * @brief Validate and dispatch the assembled NMEA sentence.
 *
 * Both GP and GN talker prefixes are accepted because receivers may emit
 * GPS-only or combined-constellation messages with the same field layout.
 *
 * @param None.
 * @return Nothing.
 * @note Called from loop() when a line-ending character completes a sentence.
 */
void handleNMEA() {
  if (nmeaIndex < 10) return;
  nmeaLine[nmeaIndex] = '\0';

  if (!checkNMEA(nmeaLine)) return;

  if (strncmp(nmeaLine, "$GPGGA", 6) == 0 || strncmp(nmeaLine, "$GNGGA", 6) == 0) {
    parseGGA(nmeaLine);
  }
  else if (strncmp(nmeaLine, "$GPRMC", 6) == 0 || strncmp(nmeaLine, "$GNRMC", 6) == 0) {
    parseRMC(nmeaLine);
  }
  else if (strncmp(nmeaLine, "$GPVTG", 6) == 0 || strncmp(nmeaLine, "$GNVTG", 6) == 0) {
    parseVTG(nmeaLine);
  }
}


/*---------------------------------------------------------------
 * GPS user-interface objects
 * Retain labels whose content or visibility changes during operation.
 *--------------------------------------------------------------*/

// Status and measurement labels updated by updateGpsDisplay().
lv_obj_t* labelStatus;
lv_obj_t* labelTime;
lv_obj_t* labelLat;
lv_obj_t* labelLon;
lv_obj_t* labelAlt;
lv_obj_t* labelSpeed;
lv_obj_t* labelSat;
lv_obj_t* labelDate;

/**
 * @brief Construct the static GPS dashboard and its dynamic labels.
 *
 * Measurement labels begin hidden because the receiver may not yet have a
 * valid fix. updateGpsDisplay() selects the appropriate presentation later.
 *
 * @param None.
 * @return Nothing.
 * @note Called once from setup() after LVGL display registration.
 */
void createGpsUI()
{
  // The screen is divided into a persistent status bar and a data region.
  lv_obj_set_style_bg_color(lv_screen_active(), lv_color_white(), LV_PART_MAIN);

  // Status bar (top colored bar)
  lv_obj_t* statusBar = lv_obj_create(lv_screen_active());
  lv_obj_set_size(statusBar, 800, 50);
  lv_obj_align(statusBar, LV_ALIGN_TOP_MID, 0, 0);
  lv_obj_set_style_bg_color(statusBar, lv_color_hex(0x1B5E), 0); // Default green
  lv_obj_set_style_radius(statusBar, 0, 0);
  lv_obj_set_style_border_width(statusBar, 0, 0);

  // Status text
  labelStatus = lv_label_create(statusBar);
  lv_label_set_text(labelStatus, "  GPS LOCKED");
  lv_obj_set_style_text_color(labelStatus, lv_color_white(), 0);
  lv_obj_set_style_text_font(labelStatus, &lv_font_montserrat_24, 0);
  lv_obj_align(labelStatus, LV_ALIGN_LEFT_MID, 10, 0);

  // Time
  labelTime = lv_label_create(statusBar);
  lv_label_set_text(labelTime, "--:--:--");
  lv_obj_set_style_text_color(labelTime, lv_color_white(), 0);
  lv_obj_set_style_text_font(labelTime, &lv_font_montserrat_16, 0);
  lv_obj_align(labelTime, LV_ALIGN_RIGHT_MID, -20, 0);

  // This central prompt is used only while no valid position is available.
  labelSat = lv_label_create(lv_screen_active());
  lv_label_set_text(labelSat, "Acquiring...");
  lv_obj_set_style_text_color(labelSat, lv_color_hex(0xC000), 0);
  lv_obj_set_style_text_font(labelSat, &lv_font_montserrat_36, 0);
  lv_obj_align(labelSat, LV_ALIGN_CENTER, 0, -60);
  lv_obj_add_flag(labelSat, LV_OBJ_FLAG_HIDDEN); // Hidden by default

  // Valid-fix measurements occupy fixed positions in the data region.
  // Latitude (large font)
  labelLat = lv_label_create(lv_screen_active());
  lv_label_set_text(labelLat, "0.00000");
  lv_obj_set_style_text_color(labelLat, lv_color_black(), 0);
  lv_obj_set_style_text_font(labelLat, &lv_font_montserrat_36, 0);
  lv_obj_align(labelLat, LV_ALIGN_TOP_LEFT, 30, 80);
  lv_obj_add_flag(labelLat, LV_OBJ_FLAG_HIDDEN);

  // Longitude (large font)
  labelLon = lv_label_create(lv_screen_active());
  lv_label_set_text(labelLon, "0.00000");
  lv_obj_set_style_text_color(labelLon, lv_color_black(), 0);
  lv_obj_set_style_text_font(labelLon, &lv_font_montserrat_36, 0);
  lv_obj_align(labelLon, LV_ALIGN_TOP_LEFT, 30, 140);
  lv_obj_add_flag(labelLon, LV_OBJ_FLAG_HIDDEN);

  // Divider line
  lv_obj_t* line = lv_line_create(lv_screen_active());
  static lv_point_precise_t line_points[] = {{30, 200}, {400, 200}};
  lv_line_set_points(line, line_points, 2);
  lv_obj_set_style_line_color(line, lv_color_hex(0xCCCCCC), 0);
  lv_obj_set_style_line_width(line, 2, 0);

  // Altitude
  labelAlt = lv_label_create(lv_screen_active());
  lv_label_set_text(labelAlt, "ALT  0.0 m");
  lv_obj_set_style_text_color(labelAlt, lv_color_black(), 0);
  lv_obj_set_style_text_font(labelAlt, &lv_font_montserrat_24, 0);
  lv_obj_align(labelAlt, LV_ALIGN_TOP_LEFT, 30, 220);
  lv_obj_add_flag(labelAlt, LV_OBJ_FLAG_HIDDEN);

  // Speed
  labelSpeed = lv_label_create(lv_screen_active());
  lv_label_set_text(labelSpeed, "SPD  0.0 km/h");
  lv_obj_set_style_text_color(labelSpeed, lv_color_black(), 0);
  lv_obj_set_style_text_font(labelSpeed, &lv_font_montserrat_24, 0);
  lv_obj_align(labelSpeed, LV_ALIGN_TOP_LEFT, 30, 260);
  lv_obj_add_flag(labelSpeed, LV_OBJ_FLAG_HIDDEN);

  // Satellites label
  lv_obj_t* satLabel = lv_label_create(lv_screen_active());
  lv_label_set_text(satLabel, "SAT");
  lv_obj_set_style_text_color(satLabel, lv_color_black(), 0);
  lv_obj_set_style_text_font(satLabel, &lv_font_montserrat_24, 0);
  lv_obj_align(satLabel, LV_ALIGN_TOP_LEFT, 30, 300);

  // Date
  labelDate = lv_label_create(lv_screen_active());
  lv_label_set_text(labelDate, "----/--/--");
  lv_obj_set_style_text_color(labelDate, lv_color_hex(0x888888), 0);
  lv_obj_set_style_text_font(labelDate, &lv_font_montserrat_16, 0);
  lv_obj_align(labelDate, LV_ALIGN_TOP_LEFT, 200, 310);
  lv_obj_add_flag(labelDate, LV_OBJ_FLAG_HIDDEN);
}

/*---------------------------------------------------------------
 * Refresh the GPS dashboard
 * Switch between acquisition and locked modes, then update live values.
 *--------------------------------------------------------------*/

/**
 * @brief Apply the latest navigation state to all dynamic UI objects.
 *
 * Visibility changes occur only when fix validity changes. Text values are`r`n * then refreshed for the active mode, avoiding meaningless`r`n * measurements while the receiver is still acquiring satellites.
 *
 * @param None.
 * @return Nothing.
 * @note Called from loop() every 800 milliseconds.
 */
void updateGpsDisplay()
{
  static bool lastValid = false;
  char buf[48];

  // A validity transition changes which group of labels is visible.
  if (gps.valid != lastValid).
  {
    if (gps.valid).
    {
      // A valid fix reveals measurements and removes the waiting prompt.
      lv_obj_add_flag(labelSat, LV_OBJ_FLAG_HIDDEN);
      lv_obj_clear_flag(labelLat, LV_OBJ_FLAG_HIDDEN);
      lv_obj_clear_flag(labelLon, LV_OBJ_FLAG_HIDDEN);
      lv_obj_clear_flag(labelAlt, LV_OBJ_FLAG_HIDDEN);
      lv_obj_clear_flag(labelSpeed, LV_OBJ_FLAG_HIDDEN);
      lv_obj_clear_flag(labelDate, LV_OBJ_FLAG_HIDDEN);
    }
    else.
    {
      // Loss of the fix hides stale measurements and restores acquisition status.
      lv_obj_clear_flag(labelSat, LV_OBJ_FLAG_HIDDEN);
      lv_obj_add_flag(labelLat, LV_OBJ_FLAG_HIDDEN);
      lv_obj_add_flag(labelLon, LV_OBJ_FLAG_HIDDEN);
      lv_obj_add_flag(labelAlt, LV_OBJ_FLAG_HIDDEN);
      lv_obj_add_flag(labelSpeed, LV_OBJ_FLAG_HIDDEN);
      lv_obj_add_flag(labelDate, LV_OBJ_FLAG_HIDDEN);
    }
    lastValid = gps.valid;
  }

  // Color provides an immediate visual distinction between locked and searching.
  lv_obj_t* statusBar = lv_obj_get_parent(labelStatus);
  if (gps.valid) {
    lv_obj_set_style_bg_color(statusBar, lv_color_hex(0x1B5E), 0); // Green.
    lv_label_set_text(labelStatus, "  GPS LOCKED");
  } else {
    lv_obj_set_style_bg_color(statusBar, lv_color_hex(0xC000), 0); // Red.
    lv_label_set_text(labelStatus, "  NO SIGNAL");
  }

  lv_label_set_text(labelTime, gps.timeStr);

  if (!gps.valid).
  {
    // Satellite count remains useful feedback even before a position is valid.
    snprintf(buf, sizeof(buf), "Satellites: %d", gps.sats);
    lv_label_set_text(labelSat, buf);
    return;
  }

  // Only a valid fix is allowed to populate the measurement labels.
  snprintf(buf, sizeof(buf), "%.5f", gps.lat);
  lv_label_set_text(labelLat, buf);

  snprintf(buf, sizeof(buf), "%.5f", gps.lon);
  lv_label_set_text(labelLon, buf);

  snprintf(buf, sizeof(buf), "ALT  %.1f m", gps.alt);
  lv_label_set_text(labelAlt, buf);

  snprintf(buf, sizeof(buf), "SPD  %.1f km/h", gps.speed);
  lv_label_set_text(labelSpeed, buf);

  lv_label_set_text(labelDate, gps.dateStr);
}

/*---------------------------------------------------------------
 * Hardware initialization.
 * Bring up shared I2C devices, display, touch, LVGL, and the GPS UART.
 *--------------------------------------------------------------*/

// Controls board-level reset and support signals through the I2C expander.
PCA9557 Out;

/**
 * @brief Initialize every hardware and software layer used by the lesson.
 *
 * The RGB frame buffers are obtained from LovyanGFX and registered directly.
 * with LVGL in full-render mode, avoiding an additional copy per frame.
 *
 * @param None.
 * @return Nothing.
 * @note Called once by the Arduino framework after reset.
 */
void setup()
{
  Serial.begin(115200);

  // The expander supplies the reset sequence required before display startup.
  Wire.begin(19, 20);
  Out.reset();
  Out.setMode(IO_OUTPUT);  
  Out.setState(IO0, IO_LOW);
  Out.setState(IO1, IO_LOW);
  delay(20);
  Out.setState(IO0, IO_HIGH);
  delay(100);
  Out.setMode(IO1, IO_INPUT);
  Serial.println("PCA9557 init done");

  // Hold the board control output low while the display is initialized.
  pinMode(38, OUTPUT);
  digitalWrite(38, LOW);

  if (!lcd.begin()) {
    Serial.println("lcd.begin() failed!");
    Serial.println("Check Arduino Tools > PSRAM is set to OPI PSRAM.");
    return;
  } else {
    Serial.println("LovyanGFX lcd.begin() OK");
  }
  delay(200);

  lv_init();
  lv_tick_set_cb(millis);
  delay(100);

  touch_init();
  Serial.println("touch init done");

  Serial.printf("screen: %u x %u\n", screenWidth, screenHeight);

  lv_color_t* frameBuffer0 = (lv_color_t*)lcd._bus_instance.getFrameBuffer(0);
  lv_color_t* frameBuffer1 = (lv_color_t*)lcd._bus_instance.getFrameBuffer(1);
  Serial.printf("LovyanGFX RGB buffers: %p, %p, free PSRAM: %u\n",
                frameBuffer0, frameBuffer1, ESP.getFreePsram());
  lv_display_t *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, frameBuffer0, frameBuffer1,
                         screenWidth * screenHeight * sizeof(lv_color_t),
                         LV_DISPLAY_RENDER_MODE_FULL);

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

#ifdef TFT_BL.
  // PWM first raises the backlight to full brightness.
  //digitalWrite(TFT_BL, HIGH);
  ledcAttach(TFT_BL, 300, 8);   
  ledcWrite(TFT_BL, 255);       
#endif.

#ifdef TFT_BL.
  // The following explicit transition preserves the board's startup sequence.
  pinMode(TFT_BL, OUTPUT);
  digitalWrite(TFT_BL, LOW); 
  delay(500);
  digitalWrite(TFT_BL, HIGH);
#endif.

  gpsSerial.begin(9600, SERIAL_8N1, GPS_RX, GPS_TX);
  createGpsUI();

  // A temporary message covers the dashboard during initial acquisition.
  lv_obj_t* startup = lv_label_create(lv_screen_active());
  lv_label_set_text(startup, "GPS Display\nWaiting for satellites...");
  lv_obj_set_style_text_color(startup, lv_color_black(), 0);
  lv_obj_set_style_text_font(startup, &lv_font_montserrat_24, 0);
  lv_obj_set_style_text_align(startup, LV_TEXT_ALIGN_CENTER, 0);
  lv_obj_align(startup, LV_ALIGN_CENTER, 0, 0);

  lv_timer_handler();
  delay(1000);
  lv_obj_delete(startup);

  Serial.println("--- GPS Display ready ---");
}

/*---------------------------------------------------------------
 * Runtime data flow.
 * Assemble UART lines, refresh the UI periodically, and service LVGL.
 *--------------------------------------------------------------*/

/**
 * @brief Process incoming GPS data and maintain the graphical interface.
 *
 * Line endings delimit NMEA sentences. A bounds check prevents a sentence.
 * from writing past nmeaLine, while UI refresh timing remains independent.
 * of the rate at which individual serial characters arrive.
 *
 * @param None.
 * @return Nothing.
 * @note Called repeatedly by the Arduino framework after setup().
 */
void loop()
{
  // A complete buffered line is parsed before collection restarts at index zero.
  while (gpsSerial.available()) {
    char c = gpsSerial.read();
    if (c == '\n' || c == '\r') {
      if (nmeaIndex > 0) {
        handleNMEA();
        nmeaIndex = 0;
      }
    } else if (nmeaIndex < sizeof(nmeaLine) - 1) {
      nmeaLine[nmeaIndex++] = c;
    }
  }

  // Throttling text updates leaves LVGL time to render and process input smoothly.
  static uint32_t lastDraw = 0;
  if (millis() - lastDraw > 800) {
    updateGpsDisplay();
    lastDraw = millis();
  }

  lv_timer_handler();
  delay(5);
}