ESP32 CYD with ESP-NOW: Control Multiple ESP32 Boards

Learn how to use ESP-NOW with the ESP32 CYD Board (Cheap Yellow Display) to control the GPIOs of multiple ESP32 boards. The CYD board acts like a remote control to send commands to other boards to control their GPIOs. We’ll control two ESP32 boards, and two GPIOs from each one. This can be easily extended to control more ESP32 boards or more GPIOs.

CYD with ESP-NOW - Control Multiple ESP32 boards

New to the ESP32 Cheap Yellow Display? Start here: Getting Started with ESP32 Cheap Yellow Display Board – CYD (ESP32-2432S028R).

Table of Contents

Project Overview

Here’s an overview of the project we’ll build.

CYD with ESP-NOW control multiple ESP32 boards
  • In this project, the ESP32 CYD board will control two GPIOs from two other ESP32 boards.
  • The ESP32 CYD board will act as an ESP-NOW sender that sends data to control the GPIOs of other ESP32 boards. We’ll control GPIOs 2 and 4 on ESP32 #1, and GPIOs 20 and 21 on ESP32 #2.
  • The CYD board acts as an ESP-NOW sender, and the other boards act as ESP-NOW receivers.
  • The CYD sender (controller board) sends messages to a specific board by referring to its MAC address.
  • To control the boards, the CYD interface shows two tabs, one for each ESP32 board. Each tab has two toggle switches to control two GPIOs from each board.

New to ESP-NOW communication protocol? Read this guide: Getting Started with ESP-NOW (ESP32 with Arduino IDE).

Prerequisites

Before proceeding, make sure you follow the next prerequisites. You must follow all steps, otherwise, your project will not work.

1) Parts Required

For this project, you need the following parts:

You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!

2) Install ESP32 Boards in Arduino IDE

Arduino IDE 2 Logo

We’ll program the ESP32 using Arduino IDE. Make sure you have the ESP32 boards installed. Follow the next tutorial:

3) Get familiar with the ESP32 Cheap Yellow Display

The ESP32-2432S028R development board has become known in the maker community as the “Cheap Yellow Display” or CYD for short. This development board, whose main chip is an ESP32-WROOM-32 module, comes with a 2.8-inch TFT touchscreen LCD, a microSD card interface, an RGB LED, and all the required circuitry to program and apply power to the board.

ESP32 Cheap Yellow Display CYD Board ESP32-2432S028R front

If this is your first time using the ESP32 Cheap Yellow Display, make sure to follow our getting started guide:

4) Install TFT and LVGL Libraries

LVGL (Light and Versatile Graphics Library) is a free and open-source graphics library that provides a wide range of easy-to-use graphical elements for your microcontroller projects that require a graphical user interface (GUI).

LVGL new logo

Follow the next tutorial to install and configure the required libraries to use LVGL for the ESP32 Cheap Yellow Display using Arduino IDE.


1) Preparing the Receiver Boards

Follow this procedure for each of your ESP32 receiver boards. In this example, we’re using two ESP32 receiver boards, but you can use only one, three, or more.

Two ESP32 Boards with LEDs MAC Address

Getting the MAC Address of the ESP32 Boards

To communicate via ESP-NOW, you need to know the MAC address of your receiver boards. To get your boards’ MAC address, upload the following code to each of your boards.

/*
  Rui Santos & Sara Santos - Random Nerd Tutorials
  Complete project details at https://RandomNerdTutorials.com/get-change-esp32-esp8266-mac-address-arduino/
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.  
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
#include <WiFi.h>
#include <esp_wifi.h>

void readMacAddress(){
  uint8_t baseMac[6];
  esp_err_t ret = esp_wifi_get_mac(WIFI_IF_STA, baseMac);
  if (ret == ESP_OK) {
    Serial.printf("%02x:%02x:%02x:%02x:%02x:%02x\n",
                  baseMac[0], baseMac[1], baseMac[2],
                  baseMac[3], baseMac[4], baseMac[5]);
  } else {
    Serial.println("Failed to read MAC address");
  }
}

void setup(){
  Serial.begin(115200);

  WiFi.mode(WIFI_STA);
  WiFi.STA.begin();

  Serial.print("[DEFAULT] ESP32 Board MAC Address: ");
  readMacAddress();
}
 
void loop(){

}

View raw code

After uploading, open the Serial Monitor and reset the ESP32. Its MAC address will be printed. Save it because you’ll need it on the ESP32 CYD sender code.

Get the MAC address for all your receiver boards.

For example, in my case, I get:

  • ESP32 Receiver Board 1: 30:ae:a4:f6:7d:4c
  • ESP32 Receiver Board 2: 34:85:18:40:2f:cc
ESP32 Get MAC address Serial Monitor

Wiring the Circuit

We’ll control two GPIOs on each ESP32 board. We’ll control GPIOs 2 and 4 on our ESP32 DOIT board, and GPIOs 20 and 21 on our ESP32S3. You can control any other GPIOs.

We’ll connect an LED to each GPIO we want to control.

ESP32 #1ESP32 #2
GPIO 2GPIO 20
GPIO 4GPIO 21

Here’s an example diagram showing how to connect two LEDs to your board.

ESP32 with two LEDs connected - wiring the circuit

Code – ESP-NOW ESP32 Receiver Boards

Upload the following code to each of your receiver boards. This code will work for any GPIOs you want to control.

/*********
  Rui Santos & Sara Santos - Random Nerd Tutorials
  Complete project details at https://RandomNerdTutorials.com/esp32-cyd-esp-now-control-multiple-boards/
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
  The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*********/
#include <esp_now.h>
#include <WiFi.h>

// Structure example to receive data
// Must match the sender structure
typedef struct struct_message {
    int gpio;
    bool state;
} struct_message;

// Create a struct_message called myData
struct_message myData;

// callback function that will be executed when data is received
void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {
  memcpy(&myData, incomingData, sizeof(myData));
  Serial.print("Bytes received: ");
  Serial.println(len);
  Serial.print("Setting GPIO: ");
  Serial.println(myData.gpio);
  Serial.print("state to: ");
  Serial.println(myData.state);
  // Control GPIO
  pinMode(myData.gpio, OUTPUT);
  digitalWrite(myData.gpio, myData.state);
  Serial.println();
}
 
void setup() {
  // Initialize Serial Monitor
  Serial.begin(115200);
  
  // Initialize WIFI STA interface
  WiFi.mode(WIFI_STA);

  // Init ESP-NOW
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }
  
  // Once ESPNow is successfully Init, we will register for recv CB to
  // get recv packer info
  esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));
}
 
void loop() {

}

View raw code

How Does the Code Work?

Let’s take a quick look at how the receiver code works. Alternatively, you can skip to the next section.

Include Libraries

First, include the esp_now.h and the WiFi.h libraries (required to initialize the Wi-Fi station interface to use ESP-NOW).

#include <esp_now.h>
#include <WiFi.h>

Data Structure to Receive Data

Create a structure to receive the data. We call it struct_message. This structure should be the same as that defined in the sender sketch. The structure will contain the data received via ESP-NOW. The data contains the GPIO number that we want to control (int gpio) and its state (bool state).

typedef struct struct_message {
    int gpio;
    bool state;
} struct_message;

After creating the structure, create a struct_message variable called myData, where we’ll store the data received via ESP-NOW.

struct_message myData;

ESP-NOW Receive Callback Function

Create a callback function that is called when the ESP32 receives the data via ESP-NOW. The function is called onDataRecv() and should accept several parameters: the sender’s MAC address, the incoming message (data structure), and the message length.

void OnDataRecv(const uint8_t * mac, const uint8_t *incomingData, int len) {

This line copies the content of the incomingData data variable into the myData structure variable.

memcpy(&myData, incomingData, sizeof(myData));

Now, the myData structure contains two variables with the values sent by the sender CYD board. You can access the variable gpio with myData.gpio, and access the state with myData.state. We print that data in the Serial Monitor.

Serial.print("Bytes received: ");
Serial.println(len);
Serial.print("Setting GPIO: ");
Serial.println(myData.gpio);
Serial.print("state to: ");
Serial.println(myData.state);

After receiving the data, we control the specified GPIO with the received state. First, we set the GPIO as an OUTPUT.

pinMode(myData.gpio, OUTPUT);

And finally, we control it using the digitalWrite() function and passing as an argument the GPIO (myData.gpio) and the state (myData.state)

digitalWrite(myData.gpio, myData.state);

setup()

In the setup(), initialize the Serial Monitor.

Serial.begin(115200);

Set the device as a Wi-Fi Station.

WiFi.mode(WIFI_STA);

Initialize ESP-NOW:

if (esp_now_init() != ESP_OK) {
  Serial.println("Error initializing ESP-NOW");
  return;
}

Register the callback function that will be called when data is received. In this case, we register for the OnDataRecv() function that was created previously.

esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));

loop()

Since we registered for a callback function when data is received, the ESP32 will be listening for ESP-NOW packets and will automatically run the callback function when a packet is received.

In this case, we don’t need to add anything to the loop(). It is empty, but you can add any code you need your board to run.

void loop() {

}

2) ESP32 CYD ESP-NOW Controller/Sender Board

The CYD Board will act as a ESP-NOW Controller (sender) that will send data to control the GPIOs of two other ESP32 boards.

Each GPIO will have a switch to control its state. We’ll have two different tabs, one for each board. Controlling more boards is as easy as creating more tabs.

ESP32 CYD Board Control Multiple ESP32 boards via ESP-NOW

Before uploading the following code to your CYD board, you need to insert the MAC address of the receiver boards in the next lines:

// REPLACE WITH YOUR RECEIVER'S MAC Address
uint8_t board1Address[] = {0x30, 0xAE, 0xA4, 0xF6, 0x7D, 0x4C};
uint8_t board2Address[] = {0x34, 0x85, 0x18, 0x40, 0x2F, 0xCC};
/*  Rui Santos & Sara Santos - Random Nerd Tutorials - https://RandomNerdTutorials.com/esp32-cyd-esp-now-control-multiple-boards/
    THIS EXAMPLE WAS TESTED WITH THE FOLLOWING HARDWARE:
    1) ESP32-2432S028R 2.8 inch 240Ă—320 also known as the Cheap Yellow Display (CYD): https://makeradvisor.com/tools/cyd-cheap-yellow-display-esp32-2432s028r/
      SET UP INSTRUCTIONS: https://RandomNerdTutorials.com/cyd-lvgl/
    2) REGULAR ESP32 Dev Board + 2.8 inch 240x320 TFT Display: https://makeradvisor.com/tools/2-8-inch-ili9341-tft-240x320/ and https://makeradvisor.com/tools/esp32-dev-board-wi-fi-bluetooth/
      SET UP INSTRUCTIONS: https://RandomNerdTutorials.com/esp32-tft-lvgl/
    Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/

/*  Install the "lvgl" library version 9.X by kisvegabor to interface with the TFT Display - https://lvgl.io/
    *** IMPORTANT: lv_conf.h available on the internet will probably NOT work with the examples available at Random Nerd Tutorials ***
    *** YOU MUST USE THE lv_conf.h FILE PROVIDED IN THE LINK BELOW IN ORDER TO USE THE EXAMPLES FROM RANDOM NERD TUTORIALS ***
    FULL INSTRUCTIONS AVAILABLE ON HOW CONFIGURE THE LIBRARY: https://RandomNerdTutorials.com/cyd-lvgl/ or https://RandomNerdTutorials.com/esp32-tft-lvgl/   */
#include <lvgl.h>

/*  Install the "TFT_eSPI" library by Bodmer to interface with the TFT Display - https://github.com/Bodmer/TFT_eSPI
    *** IMPORTANT: User_Setup.h available on the internet will probably NOT work with the examples available at Random Nerd Tutorials ***
    *** YOU MUST USE THE User_Setup.h FILE PROVIDED IN THE LINK BELOW IN ORDER TO USE THE EXAMPLES FROM RANDOM NERD TUTORIALS ***
    FULL INSTRUCTIONS AVAILABLE ON HOW CONFIGURE THE LIBRARY: https://RandomNerdTutorials.com/cyd-lvgl/ or https://RandomNerdTutorials.com/esp32-tft-lvgl/   */
#include <TFT_eSPI.h>

// Install the "XPT2046_Touchscreen" library by Paul Stoffregen to use the Touchscreen - https://github.com/PaulStoffregen/XPT2046_Touchscreen - Note: this library doesn't require further configuration
#include <XPT2046_Touchscreen.h>

#include <esp_now.h>
#include <WiFi.h>

// Touchscreen pins
#define XPT2046_IRQ 36   // T_IRQ
#define XPT2046_MOSI 32  // T_DIN
#define XPT2046_MISO 39  // T_OUT
#define XPT2046_CLK 25   // T_CLK
#define XPT2046_CS 33    // T_CS

SPIClass touchscreenSPI = SPIClass(VSPI);
XPT2046_Touchscreen touchscreen(XPT2046_CS, XPT2046_IRQ);

#define SCREEN_WIDTH 240
#define SCREEN_HEIGHT 320

// Touchscreen coordinates: (x, y) and pressure (z)
int x, y, z;

#define DRAW_BUF_SIZE (SCREEN_WIDTH * SCREEN_HEIGHT / 10 * (LV_COLOR_DEPTH / 8))
uint32_t draw_buf[DRAW_BUF_SIZE / 4];

// YOU MUST REPLACE WITH YOUR RECEIVER'S MAC Address
uint8_t board1Address[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
uint8_t board2Address[] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};

// Structure example to send data
// Must match the receiver structure
typedef struct struct_message {
    int gpio;
    bool state;
} struct_message;

// Create a struct_message for each board
struct_message board1Data;
struct_message board2Data;

esp_now_peer_info_t peerInfo;

// User data structure for switches
typedef struct {
    int gpio;
    struct_message* data;
    uint8_t* targetAddress;
} SwitchUserData;

// Callback when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  Serial.print("Packet to: ");
  // Copies the sender mac address to a string
  snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
           mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
  Serial.print(macStr);
  Serial.print(" send status:\t");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}

// Function to send messages via ESP-NOW
void sendEspNowMessage(const uint8_t *boardMacAddress, const struct_message &message) {
  size_t messageSize = sizeof(message);
  
  esp_err_t result = esp_now_send(boardMacAddress, (const uint8_t *) &message, messageSize);
  if (result == ESP_OK) {
    Serial.println("Sent with success");
  } 
  else if (result == ESP_ERR_ESPNOW_NOT_INIT) {
    Serial.println("Error: ESP-NOW not initialized");
  } 
  else if (result == ESP_ERR_ESPNOW_NOT_FOUND) {
    Serial.println("Error: Peer not found");
  } 
  else {
    Serial.printf("Error sending the data: %d\n", result);
  }
}

// If logging is enabled, it will inform the user about what is happening in the library
void log_print(lv_log_level_t level, const char * buf) {
  LV_UNUSED(level);
  Serial.println(buf);
  Serial.flush();
}

// Get the Touchscreen data
void touchscreen_read(lv_indev_t * indev, lv_indev_data_t * data) {
  // Checks if Touchscreen was touched, and prints X, Y and Pressure (Z)
  if(touchscreen.tirqTouched() && touchscreen.touched()) {
    // Get Touchscreen points
    TS_Point p = touchscreen.getPoint();

    // Advanced Touchscreen calibration, LEARN MORE » https://RandomNerdTutorials.com/touchscreen-calibration/
    float alpha_x, beta_x, alpha_y, beta_y, delta_x, delta_y;

    // REPLACE WITH YOUR OWN CALIBRATION VALUES » https://RandomNerdTutorials.com/touchscreen-calibration/
    alpha_x = -0.000;
    beta_x = 0.090;
    delta_x = -33.771;
    alpha_y = 0.066;
    beta_y = 0.000;
    delta_y = -14.632;

    x = alpha_y * p.x + beta_y * p.y + delta_y;
    // clamp x between 0 and SCREEN_WIDTH - 1
    x = max(0, x);
    x = min(SCREEN_WIDTH - 1, x);

    y = alpha_x * p.x + beta_x * p.y + delta_x;
    // clamp y between 0 and SCREEN_HEIGHT - 1
    y = max(0, y);
    y = min(SCREEN_HEIGHT - 1, y);

    // Basic Touchscreen calibration points with map function to the correct width and height
    //x = map(p.x, 200, 3700, 1, SCREEN_WIDTH);
    //y = map(p.y, 240, 3800, 1, SCREEN_HEIGHT);

    z = p.z;

    data->state = LV_INDEV_STATE_PRESSED;

    // Set the coordinates
    data->point.x = x;
    data->point.y = y;

    // Print Touchscreen info about X, Y and Pressure (Z) on the Serial Monitor
    Serial.print("X = ");
    Serial.print(x);
    Serial.print(" | Y = ");
    Serial.print(y);
    Serial.print(" | Pressure = ");
    Serial.print(z);
    Serial.println();
  }
  else {
    data->state = LV_INDEV_STATE_RELEASED;
  }
}

// Callback that is triggered when any toggle switch changes state
static void toggle_switch_event_handler(lv_event_t * e) {
  lv_event_code_t code = lv_event_get_code(e);
  if(code == LV_EVENT_VALUE_CHANGED) {
    lv_obj_t * toggle_switch = (lv_obj_t*) lv_event_get_target(e);
    
    void* user_data = lv_obj_get_user_data(toggle_switch);
    if (user_data == NULL) return;
    
    SwitchUserData* sw_data = (SwitchUserData*)user_data;
    
    bool is_on = lv_obj_has_state(toggle_switch, LV_STATE_CHECKED);
    
    LV_LOG_USER("GPIO %d State: %s", sw_data->gpio, is_on ? "On" : "Off");
    
    sw_data->data->gpio = sw_data->gpio;
    sw_data->data->state = is_on;
    
    sendEspNowMessage(sw_data->targetAddress, *(sw_data->data));
  }
}

void lv_create_main_gui(void) {
  // Create a Tab view object
  lv_obj_t * tabview = lv_tabview_create(lv_screen_active());
  lv_tabview_set_tab_bar_size(tabview, 40);

  // Add 2 tabs
  lv_obj_t * tab1 = lv_tabview_add_tab(tabview, "Board #1");
  lv_obj_t * tab2 = lv_tabview_add_tab(tabview, "Board #2");

  // TAB 1 - Board #1 - Add 2 Toggle Switches
  lv_obj_t * switch_label1 = lv_label_create(tab1);
  lv_label_set_text(switch_label1, "GPIO 2");
  lv_obj_align(switch_label1, LV_ALIGN_CENTER, 0, -80);

  lv_obj_t * switch1 = lv_switch_create(tab1);
  lv_obj_align(switch1, LV_ALIGN_CENTER, 0, -40);
  
  static SwitchUserData switch1_data = {2, &board1Data, board1Address};
  lv_obj_set_user_data(switch1, &switch1_data);
  lv_obj_add_event_cb(switch1, toggle_switch_event_handler, LV_EVENT_ALL, NULL);

  lv_obj_t * switch_label2 = lv_label_create(tab1);
  lv_label_set_text(switch_label2, "GPIO 4");
  lv_obj_align(switch_label2, LV_ALIGN_CENTER, 0, 10);

  lv_obj_t * switch2 = lv_switch_create(tab1);
  lv_obj_align(switch2, LV_ALIGN_CENTER, 0, 50);
  
  static SwitchUserData switch2_data = {4, &board1Data, board1Address};
  lv_obj_set_user_data(switch2, &switch2_data);
  lv_obj_add_event_cb(switch2, toggle_switch_event_handler, LV_EVENT_ALL, NULL);

  // TAB 2 - Board #2 - Add 2 Toggle Switches
  lv_obj_t * switch_label3 = lv_label_create(tab2);
  lv_label_set_text(switch_label3, "GPIO 20");
  lv_obj_align(switch_label3, LV_ALIGN_CENTER, 0, -80);

  lv_obj_t * switch3 = lv_switch_create(tab2);
  lv_obj_align(switch3, LV_ALIGN_CENTER, 0, -40);
  
  static SwitchUserData switch3_data = {20, &board2Data, board2Address};
  lv_obj_set_user_data(switch3, &switch3_data);
  lv_obj_add_event_cb(switch3, toggle_switch_event_handler, LV_EVENT_ALL, NULL);

  lv_obj_t * switch_label4 = lv_label_create(tab2);
  lv_label_set_text(switch_label4, "GPIO 21");
  lv_obj_align(switch_label4, LV_ALIGN_CENTER, 0, 10);

  lv_obj_t * switch4 = lv_switch_create(tab2);
  lv_obj_align(switch4, LV_ALIGN_CENTER, 0, 50);
  
  static SwitchUserData switch4_data = {21, &board2Data, board2Address};
  lv_obj_set_user_data(switch4, &switch4_data);
  lv_obj_add_event_cb(switch4, toggle_switch_event_handler, LV_EVENT_ALL, NULL);
}

void setup() {
  String LVGL_Arduino = String("LVGL Library Version: ") + lv_version_major() + "." + lv_version_minor() + "." + lv_version_patch();
  Serial.begin(115200);
  Serial.println(LVGL_Arduino);

  // Set device as a Wi-Fi Station
  WiFi.mode(WIFI_STA);

  // Init ESP-NOW
  if (esp_now_init() != ESP_OK) {
    Serial.println("Error initializing ESP-NOW");
    return;
  }
    
  // Once ESPNow is successfully Init, we will register for Send CB to
  // get the status of Trasnmitted packet
  esp_now_register_send_cb(esp_now_send_cb_t(OnDataSent));

  // Register peers
  peerInfo.channel = 0;  
  peerInfo.encrypt = false;

  // Register first peer  
  memcpy(peerInfo.peer_addr, board1Address, 6);
  if (esp_now_add_peer(&peerInfo) != ESP_OK){
    Serial.println("Failed to add peer");
    return;
  }
  // Register second peer  
  memcpy(peerInfo.peer_addr, board2Address, 6);
  if (esp_now_add_peer(&peerInfo) != ESP_OK){
    Serial.println("Failed to add peer");
    return;
  }

  // Start LVGL
  lv_init();
  // Register print function for debugging
  lv_log_register_print_cb(log_print);

  // Start the SPI for the touchscreen and init the touchscreen
  touchscreenSPI.begin(XPT2046_CLK, XPT2046_MISO, XPT2046_MOSI, XPT2046_CS);
  touchscreen.begin(touchscreenSPI);
  // Set the Touchscreen rotation in landscape mode
  // Note: in some displays, the touchscreen might be upside down, so you might need to set the rotation to 0: touchscreen.setRotation(0);
  touchscreen.setRotation(2);

  // Create a display object
  lv_display_t * disp;
  // Initialize the TFT display using the TFT_eSPI library
  disp = lv_tft_espi_create(SCREEN_WIDTH, SCREEN_HEIGHT, draw_buf, sizeof(draw_buf));
  lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_270);
  
  // Initialize an LVGL input device object (Touchscreen)
  lv_indev_t * indev = lv_indev_create();
  lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
  // Set the callback function to read Touchscreen input
  lv_indev_set_read_cb(indev, touchscreen_read);

  // Function to draw the GUI
  lv_create_main_gui();
}

void loop() {
  lv_task_handler();  // let the GUI do its work
  lv_tick_inc(5);     // tell LVGL how much time has passed
  delay(5);           // let this time pass
}

View raw code

We cover how to create tabs, text fields, buttons, and much more in our LVGL for the ESP32 eBook. Check it out here: Learn LVGL: Build GUIs for ESP32 Projects (eBook).

How Does the Code Work?

Now, let’s take a quick look at how the code works, or skip to the demonstration section.

Including Libraries

In all your LVGL sketches, you need to include the lvgl.h and the TFT_eSPI.h libraries to display on the screen.

#include <lvgl.h>
#include <TFT_eSPI.h>

We also use the touchscreen to choose between tabs and click on the switches. So, we need to include the touchscreen library.

#include <XPT2046_Touchscreen.h>

You need to include the WiFi and esp_now libraries to use ESP-NOW.

#include <esp_now.h>
#include <WiFi.h>

Initialize Touchscreen

The following lines set the touchscreen pinout:

#define XPT2046_IRQ 36
#define XPT2046_MOSI 32
#define XPT2046_MISO 39
#define XPT2046_CLK 25
#define XPT2046_CS 33

Create a touchscreenSPI and touchscreen instances:

SPIClass touchscreenSPI = SPIClass(VSPI);
XPT2046_Touchscreen touchscreen(XPT2046_CS, XPT2046_IRQ);

Defining the Display Width and Height

You need to define your display width and height in all your sketches that use LVGL. If you’re using the recommended display, the size is 240×320.

#define SCREEN_WIDTH 240
#define SCREEN_HEIGHT 320

X, Y, and Z Touchscreen Variables

Define the variables to hold the touch coordinates and touch pressure.

// Touchscreen coordinates: (x, y) and pressure (z)
int x, y, z;

Creating a Drawing Buffer

You need to create a buffer to draw on the display as follows:

#define DRAW_BUF_SIZE (SCREEN_WIDTH * SCREEN_HEIGHT / 10 * (LV_COLOR_DEPTH / 8))
uint32_t draw_buf[DRAW_BUF_SIZE / 4];

Receiver Boards’ MAC Address

Modify the following lines with the MAC address of your receiver boards. For example, in my case:

  • ESP32 Receiver Board 1: 30:ae:a4:f6:7d:4c
  • ESP32 Receiver Board 2: 34:85:18:40:2f:cc

So, it will be like this:

// REPLACE WITH YOUR RECEIVER'S MAC Address
uint8_t board1Address[] = {0x30, 0xAE, 0xA4, 0xF6, 0x7D, 0x4C};
uint8_t board2Address[] = {0x34, 0x85, 0x18, 0x40, 0x2F, 0xCC};

Data Structure to Send Data

Create a data structure to send data to the other ESP32 boards to control their GPIOs. This must be the same structure set in the receiver boards. The structure contains the GPIO to be controlled (int gpio) and the state (bool state), either ON (true) or OFF(false).

// Structure example to send data
// Must match the receiver structure
typedef struct struct_message {
    int gpio;
    bool state;
} struct_message;

Now, created two variables of type struct_message, one for each board: board1Data and board2Data.

struct_message board1Data;
struct_message board2Data;

ESP-NOW Peer Info

Create a variable of type esp_now_peer_info that will be used to hold data about ESP-NOW peers (the receiver boards).

esp_now_peer_info_t peerInfo;

Structure for LVGL Switches

Create a data structure to hold data about the switches called SwitchUserData. This type of data structure will hold the GPIO we want to control (so that we know which switch was toggled), a structure of type struct_message that we created previously, and the MAC address of the receiver board (so that we know to which board that switch belongs).

// User data structure for switches
typedef struct {
    int gpio;
    struct_message* data;
    uint8_t* targetAddress;
} SwitchUserData;

To better understand this structure, here’s a simple diagram.

CYD Control Multiple Boards via ESP-NOW Data structures

onDatSent() ESP-NOW Callback Function

The onDataSent() function will run when the board sends a message via ESP-NOW. It simply plrints a debuggins message.

// Callback when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  Serial.print("Packet to: ");
  // Copies the sender mac address to a string
  snprintf(macStr, sizeof(macStr), "%02x:%02x:%02x:%02x:%02x:%02x",
           mac_addr[0], mac_addr[1], mac_addr[2], mac_addr[3], mac_addr[4], mac_addr[5]);
  Serial.print(macStr);
  Serial.print(" send status:\t");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");
}

Function to send ESP-NOW Messages

The sendEspNowMessage() function sends a message via ESP-NOW. It accepts as arguments the MAC address of the receiver board and the data to be sent (a variable of type struct_message).

// Function to send messages via ESP-NOW
void sendEspNowMessage(const uint8_t *boardMacAddress, const struct_message &message) {
  size_t messageSize = sizeof(message);
  
  esp_err_t result = esp_now_send(boardMacAddress, (const uint8_t *) &message, messageSize);
  if (result == ESP_OK) {
    Serial.println("Sent with success");
  } 
  else if (result == ESP_ERR_ESPNOW_NOT_INIT) {
    Serial.println("Error: ESP-NOW not initialized");
  } 
  else if (result == ESP_ERR_ESPNOW_NOT_FOUND) {
    Serial.println("Error: Peer not found");
  } 
  else {
    Serial.printf("Error sending the data: %d\n", result);
  }
}

Debugging Function

For debugging with the LVGL library, you should use the log_print() function. It is defined below. Include it in all your sketches before the setup().

// If logging is enabled, it will inform the user about what is happening in the library
void log_print(lv_log_level_t level, const char * buf) {
  LV_UNUSED(level);
  Serial.println(buf);
  Serial.flush();
}

Touchscreen Callback Function

The touchscreen_read() callback function will be called when touch is detected on the touchscreen.

// Get the Touchscreen data
void touchscreen_read(lv_indev_t * indev, lv_indev_data_t * data) {
  // Checks if Touchscreen was touched, and prints X, Y and Pressure (Z)
  if(touchscreen.tirqTouched() && touchscreen.touched()) {
    // Get Touchscreen points
    TS_Point p = touchscreen.getPoint();


(....)
  }
}

LVGL Main Function – Drawing the GUI

The lv_create_main_gui() function will draw the GUI on the display. This function will be called later in the setup().

void lv_create_main_gui(void) {

First, we create a tabview where we’ll add our tabs.

// Create a Tab view object
lv_obj_t * tabview;
tabview = lv_tabview_create(lv_screen_active());
lv_tabview_set_tab_bar_size(tabview, 40);

Then, we create the tabs we want to display and add them to the tabview. We’re creating two tabs, one for each board.

lv_obj_t * tab1 = lv_tabview_add_tab(tabview, "BOARD #1");
lv_obj_t * tab2 = lv_tabview_add_tab(tabview, "BOARD #2");

Create the Toggle Switches

Then, we create a label for the first switch and add it to tab1.

lv_obj_t * switch_label1 = lv_label_create(tab1);

We set the text label to GPIO 2.

lv_label_set_text(switch_label1, "GPIO 2");

And finally, we align it on the screen.

lv_obj_align(switch_label1, LV_ALIGN_CENTER, 0, -80);

Next, we create the actual switch on tab1 and align it on the screen under the label.

lv_obj_t * switch1 = lv_switch_create(tab1);
lv_obj_align(switch1, LV_ALIGN_CENTER, 0, -40);

Then, we create a SwitchUserData structure (defined earlier) called switch1_data containing the GPIO number for the switch, the data structure to send, and the MAC address of the receiver board controlled by that switch.

static SwitchUserData switch1_data = {2, &board1Data, board1Address};

We can attach extra data to the switch so it can be accessed inside the callback function that runs when the switch is toggled. In this case, we attach the switch1_data structure, which stores the GPIO the switch is controlling, the data structure to send, and the MAC address of the receiver board. This is done using the lv_obj_set_user_data() function.

lv_obj_set_user_data(switch1, &switch1_data);

Finally, we attach the callback function to the switch using the lv_obj_add_event_cb().

lv_obj_add_event_cb(switch1, toggle_switch_event_handler, LV_EVENT_ALL, NULL);

We follow a similar procedure for the other switch. This snippet controls GPIO 4.

lv_obj_t * switch_label2 = lv_label_create(tab1);
lv_label_set_text(switch_label2, "GPIO 4");
lv_obj_align(switch_label2, LV_ALIGN_CENTER, 0, 10);

lv_obj_t * switch2 = lv_switch_create(tab1);
lv_obj_align(switch2, LV_ALIGN_CENTER, 0, 50);
  
static SwitchUserData switch2_data = {4, &board1Data, board1Address};
lv_obj_set_user_data(switch2, &switch2_data);
lv_obj_add_event_cb(switch2, toggle_switch_event_handler, LV_EVENT_ALL, NULL);

Then, we create another tab with two more toggle switches. These will control GPIOs 20 and 21 of the other ESP32 receiver board (ESP32 #2).

// TAB 2 - Board #2 - Add 2 Toggle Switches
lv_obj_t * switch_label3 = lv_label_create(tab2);
lv_label_set_text(switch_label3, "GPIO 20");
lv_obj_align(switch_label3, LV_ALIGN_CENTER, 0, -80);

lv_obj_t * switch3 = lv_switch_create(tab2);
lv_obj_align(switch3, LV_ALIGN_CENTER, 0, -40);
  
static SwitchUserData switch3_data = {20, &board2Data, board2Address};
lv_obj_set_user_data(switch3, &switch3_data);
lv_obj_add_event_cb(switch3, toggle_switch_event_handler, LV_EVENT_ALL, NULL);

lv_obj_t * switch_label4 = lv_label_create(tab2);
lv_label_set_text(switch_label4, "GPIO 21");
lv_obj_align(switch_label4, LV_ALIGN_CENTER, 0, 10);

lv_obj_t * switch4 = lv_switch_create(tab2);
lv_obj_align(switch4, LV_ALIGN_CENTER, 0, 50);
  
static SwitchUserData switch4_data = {21, &board2Data, board2Address};
lv_obj_set_user_data(switch4, &switch4_data);
lv_obj_add_event_cb(switch4, toggle_switch_event_handler, LV_EVENT_ALL, NULL);

We handle the toggle switch events in the toggle_switch_event_handler() function. All toggle switches trigger the same function, but attach different data to the switch (different structure variables of type SwitchUserData). Alternatively, we could have created a different function for each switch.

toggle_switch_event_handler – Toggle Switches Callback Function

When an event happens on the switch, the toggle_switch_event_handler() function runs.

static void toggle_switch_event_handler(lv_event_t * e) {

In the following lines, we get the event that triggered the callback function. It is saved in the code variable.

lv_event_code_t code = lv_event_get_code(e);

We check if the event LV_EVENT_VALUE_CHANGED happened (if the toggle switch value has changed).

if(code == LV_EVENT_VALUE_CHANGED) {

We get the object that triggered the event, so that we know which switch it was.

lv_obj_t * toggle_switch = (lv_obj_t*) lv_event_get_target(e);

More information about creating toggle switches in the LVGL documentation.

We get access to the custom data that we previously attached to the switch. We save it in the sw_data structure variable.

void* user_data = lv_obj_get_user_data(toggle_switch);
if (user_data == NULL) return;
    
SwitchUserData* sw_data = (SwitchUserData*)user_data;

We check whether the toggle switch is ON or OFF.

bool is_on = lv_obj_has_state(toggle_switch, LV_STATE_CHECKED);

If LV_STATE_CHECKED is true (the toggle switch is checked), it returns true. Otherwise, it returns false.

Now, we know the toggle switch state and which state we should send to the receiver board to control the GPIO. The state is now saved in the is_on variable.

Now, we can update our data structure that is inside the sw_data structure with the GPIO we want to control and the state:

sw_data->data->gpio = sw_data->gpio;
sw_data->data->state = is_on;

Now, we can finally call the sendEspNowMessage() to send data to the other boards. Pass as arguments the board address and the data we want to send. We want to send the data structure that is inside the sw_data.

sendEspNowMessage(sw_data->targetAddress, *(sw_data->data));

setup()

In the setup(), include the following lines for debugging. These will print the version of LVGL that you’re using. You must be using version 9.

String LVGL_Arduino = String("LVGL Library Version: ") + lv_version_major() + "." + lv_version_minor() + "." + lv_version_patch();
Serial.begin(115200);
Serial.println(LVGL_Arduino);

Initialize ESP-NOW

Activate the Wi-Fi interface and initialize ESP-NOW.

// Set device as a Wi-Fi Station
WiFi.mode(WIFI_STA);

// Initialize ESP-NOW
if (esp_now_init() != ESP_OK) {
  Serial.println("Error initializing ESP-NOW");
  return;
}

ESP-NOW callback function

Register for an ESP-NOW Sending Callback Function. In this case, it’s the OnDataSent we’ve seen previously.

esp_now_register_send_cb(esp_now_send_cb_t(OnDataSent));

Register the receiver boards as ESP-NOW peers.

// Register first peer  
memcpy(peerInfo.peer_addr, board1Address, 6);
if (esp_now_add_peer(&peerInfo) != ESP_OK){
  Serial.println("Failed to add peer");
  return;
}
// Register second peer  
memcpy(peerInfo.peer_addr, board2Address, 6);
if (esp_now_add_peer(&peerInfo) != ESP_OK){
  Serial.println("Failed to add peer");
  return;
}

Initialize the LVGL Library

Initialize the LVGL Library by calling the lv_init() function in the setup().

// Start LVGL
lv_init();

Register Debugging Function

Register your log_print() function declared previously as a function associated with debugging LVGL.

// Register print function for debugging
lv_log_register_print_cb(log_print);

Start the Touchscreen

Start the SPI for the touchscreen and initialize the touchscreen.

touchscreenSPI.begin(XPT2046_CLK, XPT2046_MISO, XPT2046_MOSI, XPT2046_CS);
touchscreen.begin(touchscreenSPI);
touchscreen.setRotation(2);

Note: in some displays, the touchscreen might be upside down, so you might need to set the rotation to 0: touchscreen.setRotation(0);

Creating a Display Object

Create the display object and initialize the TFT display using the TFT_eSPI library.

  // Create a display object
  lv_display_t * disp;
  // Initialize the TFT display using the TFT_eSPI library
  disp = lv_tft_espi_create(SCREEN_WIDTH, SCREEN_HEIGHT, draw_buf, sizeof(draw_buf));
  lv_display_set_rotation(disp, LV_DISPLAY_ROTATION_270);

Initialize an LVGL input device object (touchscreen) and set the callback function that will be triggered when you click the touchscreen. It’s the touchscreen_read() function we created previously.

// Initialize an LVGL input device object (Touchscreen)
lv_indev_t * indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
// Set the callback function to read Touchscreen input
lv_indev_set_read_cb(indev, touchscreen_read);

Drawing the GUI

Call the lv_create_main_gui() function to draw the GUI for your touchscreen:

lv_create_main_gui();

loop()

Keep LVGL running by calling the following functions in the loop().

void loop() {
  lv_task_handler();  // let the GUI do its work
  lv_tick_inc(5);     // tell LVGL how much time has passed
  delay(5);           // let this time pass
}

3) Demonstration

Upload the code to your CYD board. Go to Tools Board and select ESP32 ESP32 Dev Module. Then, select the right COM port in Tools Port.

Go to Tools > Partition scheme > choose anything that has more than 1.4MB APP, for example: “Huge APP (3MB No OTA/1MB SPIFFS“.

Select Huge App Partion Scheme Arduino IDE Tools Menu

Finally, click the upload button.

Arduino IDE 2 Upload Button

With the ESP32 receiver boards running the ESP-NOW receiver code, now you can use the CYD touchscreen display to control their GPIOs. You can change tabs using the navigation on top of the display to switch between Board #1 and Board #2.

LVGL ESP32 CYD with ESPNOW Control Multiple ESP32 Boards Demonstration

You can watch the following quick video demonstration.


Wrapping Up

In this tutorial, you learned how to control multiple ESP32 boards outputs with the CYD board using the ESP-NOW communication protocol.

We have another ESP-NOW tutorial with the CYD board that receives data from multiple ESP32 boards and displays it on different tables:

We hope you found this tutorial useful. If you would like to learn more about creating graphical user interfaces using the LVGL library with the ESP32, check out our eBook:

Other guides you might like reading:

To learn more about the ESP32, make sure to take a look at our resources:



Learn how to build a home automation system and we’ll cover the following main subjects: Node-RED, Node-RED Dashboard, Raspberry Pi, ESP32, ESP8266, MQTT, and InfluxDB database DOWNLOAD »
Learn how to build a home automation system and we’ll cover the following main subjects: Node-RED, Node-RED Dashboard, Raspberry Pi, ESP32, ESP8266, MQTT, and InfluxDB database DOWNLOAD »

Enjoyed this project? Stay updated by subscribing our newsletter!

Leave a Comment

Download Our Free eBooks and Resources

Get instant access to our FREE eBooks, Resources, and Exclusive Electronics Projects by entering your email address below.