Перевод руководства ESP32 ESP-NOW Web Server with Multiple BoardsRandom Nerd Tutorials.

Учимся создавать веб-сервер на ESP32, который использует ESP-NOW для управления GPIO нескольких плат ESP32. Веб-сервер работает как пульт дистанционного управления, отправляя команды ESP-NOW другим платам для управления их GPIO. Мы будем управлять двумя платами ESP32 и двумя GPIO на каждой. Это легко расширить для управления большим количеством плат ESP32 или GPIO.

Вам также может понравиться: Building an ESP32 Web Server: The Complete Guide for Beginners.

Обзор проекта#

Вот обзор проекта, который мы построим.

  • В этом проекте одна плата ESP32 (контроллер) будет хостить веб-сервер, отображающий веб-страницу с кнопками для управления двумя GPIO двух других плат ESP32.

  • Плата-контроллер ESP32 будет действовать как отправитель ESP-NOW, отправляя данные для управления GPIO других плат ESP32. Мы будем управлять GPIO 2 и 4 на ESP32 #1 и GPIO 20 и 21 на ESP32 #2.

  • Плата-контроллер ESP32 действует как отправитель ESP-NOW, а другие платы — как приёмники ESP-NOW.

  • Плата-контроллер отправляет сообщения конкретной плате, ссылаясь на её MAC-адрес.

  • Для управления платами веб-сервер показывает две карточки, по одной для каждой платы. Каждая карточка содержит два переключателя для управления двумя GPIO каждой платы.

  • Если команда ESP-NOW не доставлена успешно, переключатель возвращается в последнее успешно доставленное состояние.

Новичок в протоколе ESP-NOW? Читайте это руководство: Getting Started with ESP-NOW (ESP32 with Arduino IDE).

Предварительные требования#

Прежде чем продолжить, убедитесь, что выполнили предварительные требования. Необходимо выполнить все шаги, иначе проект не будет работать.

1) Необходимые детали#

Для этого проекта вам понадобятся следующие детали:

2) Установка плат ESP32 в Arduino IDE#

Мы будем программировать ESP32 using Arduino IDE. Убедитесь, что у вас установлены платы ESP32. Следуйте следующему руководству:

3) Установка библиотек ESPAsyncWebServer#

Для создания веб-сервера ESP32 мы будем использовать следующие библиотеки.

Вы можете установить эти библиотеки в Arduino Library Manager. Откройте Library Manager, нажав иконку библиотеки в левой боковой панели.

Найдите ESPAsyncWebServer и установите ESPAsyncWebServer by ESP32Async.

Найдите AsyncTCP и установите AsyncTCP by ESP32Async.

1) Подготовка плат-приёмников#

Выполните эту процедуру для каждой из ваших плат-приёмников ESP32. В этом примере мы используем две платы-приёмника ESP32, но вы можете использовать только одну, три или больше.

Получение MAC-адреса плат ESP32#

Для связи через ESP-NOW вам нужно знать MAC-адрес ваших плат-приёмников. Чтобы получить MAC-адрес ваших плат, загрузите следующий код на каждую из ваших плат.

/*
 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

После загрузки откройте Serial Monitor и перезагрузите ESP32. Его MAC-адрес будет напечатан. Сохраните его, потому что он понадобится в коде отправителя веб-сервера ESP32.

Получите MAC-адрес для всех ваших плат-приёмников.

Например, в моём случае я получил:

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

Схема подключения#

Мы будем управлять двумя GPIO на каждой плате ESP32. Мы будем управлять GPIO 2 и 4 на нашей плате ESP32 DOIT и GPIO 20 и 21 на нашей ESP32S3. Вы можете управлять любыми другими GPIO.

Мы подключим светодиод к каждому GPIO, которым хотим управлять.

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

Код — платы-приёмники ESP-NOW ESP32#

Загрузите следующий код на каждую из ваших плат-приёмников. Этот код будет работать для любых GPIO, которыми вы хотите управлять.

/*********
 Rui Santos & Sara Santos - Random Nerd Tutorials
 Complete project details at https://RandomNerdTutorials.com/esp32-esp-now-web-server-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

Как работает код?#

Давайте быстро рассмотрим, как работает код приёмника. Альтернативно, вы можете перейти к следующему разделу.

Подключение библиотек#

Сначала подключите библиотеки esp_now.h и WiFi.h (требуется для инициализации интерфейса Wi-Fi station для использования ESP-NOW).

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

Структура данных для приёма данных#

Создайте структуру для приёма данных. Мы называем её struct_message. Эта структура должна быть такой же, как определённая в скетче отправителя. Структура будет содержать данные, полученные через ESP-NOW. Данные содержат номер GPIO, которым мы хотим управлять (int gpio), и его состояние (bool state).

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

После создания структуры создайте переменную struct_message с именем myData, где мы будем хранить данные, полученные через ESP-NOW.

struct_message myData;

Callback-функция приёма ESP-NOW#

Создайте callback-функцию, которая вызывается, когда ESP32 получает данные через ESP-NOW. Функция называется onDataRecv() и должна принимать несколько параметров: MAC-адрес отправителя, входящее сообщение (структуру данных) и длину сообщения.

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

Эта строка копирует содержимое переменной incomingData в переменную структуры myData.

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

Теперь структура myData содержит две переменные со значениями, отправленными платой-отправителем. Вы можете получить доступ к переменной gpio с помощью myData.gpio и к состоянию с помощью myData.state. Мы выводим эти данные в 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);

После получения данных мы управляем указанным GPIO с полученным состоянием. Сначала мы устанавливаем GPIO как OUTPUT.

pinMode(myData.gpio, OUTPUT);

И наконец, мы управляем им с помощью функции digitalWrite(), передавая в качестве аргумента GPIO (myData.gpio) и состояние (myData.state)

digitalWrite(myData.gpio, myData.state);

setup()#

В setup() инициализируйте Serial Monitor.

Serial.begin(115200);

Установите устройство как Wi-Fi Station.

WiFi.mode(WIFI_STA);

Инициализируйте ESP-NOW:

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

Зарегистрируйте callback-функцию, которая будет вызываться при получении данных. В данном случае мы регистрируем функцию OnDataRecv(), созданную ранее.

esp_now_register_recv_cb(esp_now_recv_cb_t(OnDataRecv));

loop()#

Поскольку мы зарегистрировали callback-функцию для приёма данных, ESP32 будет слушать пакеты ESP-NOW и автоматически запускать callback-функцию при получении пакета.

В данном случае нам не нужно ничего добавлять в loop(). Он пуст, но вы можете добавить любой код, который нужен вашей плате.

void loop() {

}

2) Плата-контроллер/отправитель ESP-NOW с веб-сервером ESP32#

Плата-контроллер ESP32 будет хостить веб-сервер для отправки данных для управления GPIO двух других плат ESP32. Вот код:

/*********
 Rui Santos & Sara Santos - Random Nerd Tutorials
 Complete instructions at https://RandomNerdTutorials.com/esp32-esp-now-web-server-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 <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <esp_now.h>

// REPLACE WITH YOUR WI-FI CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

// 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 to hold GPIO state
typedef struct struct_message {
  int gpio;
  bool state;
} struct_message;

struct_message board1Data;
struct_message board2Data;

// Confirmed delivered states
bool board1Btn1 = false;
bool board1Btn2 = false;
bool board2Btn1 = false;
bool board2Btn2 = false;

// Pending messages delivery tracking
struct PendingUpdate {
  bool active = false;
  int board;
  int gpio;
  bool newState;
  bool oldState;
};

PendingUpdate pendingUpdate;

// GPIO pin definitions
const int board1Pin1 = 2;
const int board1Pin2 = 4;
const int board2Pin1 = 20;
const int board2Pin2 = 21;

// Global variables
esp_now_peer_info_t peerInfo;
AsyncWebServer server(80);

// Function to get a pointer to the state variable based on board and GPIO
bool* getStatePtr (int board, int gpio) {
  if (board == 1 && gpio == board1Pin1) return &board1Btn1;
  if (board == 1 && gpio == board1Pin2) return &board1Btn2;
  if (board == 2 && gpio == board2Pin1) return &board2Btn1;
  if (board == 2 && gpio == board2Pin2) return &board2Btn2;
  return nullptr;
}

// ESP-NOW callback function when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  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("Packet to: ");
  Serial.print(macStr);
  Serial.print(" send status:\t");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");

  if (!pendingUpdate.active) return;

  // Ensure the pending update corresponds to the correct board and GPIO
  bool* statePtr = getStatePtr(pendingUpdate.board, pendingUpdate.gpio);
  if (statePtr == nullptr) {
    pendingUpdate.active = false;
    return;
  }

  // Commit or revert the state based on delivery status
  if (status == ESP_NOW_SEND_SUCCESS) {
    *statePtr = pendingUpdate.newState;
    Serial.printf("State committed: board=%d gpio=%d state=%d\n",
                  pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.newState);
  } else {
    *statePtr = pendingUpdate.oldState;
    Serial.printf("Delivery failed — state reverted: board=%d gpio=%d back to %d\n",
                  pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.oldState);
  }
  pendingUpdate.active = false;
}

// Function to send ESP-NOW message
esp_err_t sendEspNowMessage(const uint8_t *boardMacAddress, const struct_message &message) {
  esp_err_t result = esp_now_send(boardMacAddress, (const uint8_t *) &message, sizeof(message));
  // Handle the result of the send operation
  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);
  }
  return result;
}

// HTML Web Page
String renderHTML() {
  String html = R"rawliteral(
<!DOCTYPE HTML>
<html>
<head>
  <title>ESP32 - ESP-NOW Controller</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    body { font-family: Arial; text-align: center; margin: 0; padding: 20px; background: #f4f4f4; }
    h2 { color: #333; }
    .card { background: white; padding: 20px; margin: 15px auto; max-width: 420px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
    .switch-container { margin: 15px 0; display: flex; align-items: center; justify-content: space-between; padding: 10px 0; }
    .switch-label { font-size: 18px; font-weight: 500; }
    .switch { position: relative; display: inline-block; width: 60px; height: 34px; }
    .switch input { opacity: 0; width: 0; height: 0; }
    .slider {
      position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
      background-color: #ccc; transition: .4s; border-radius: 34px;
    }
    .slider:before {
      position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px;
      background-color: white; transition: .4s; border-radius: 50%;
    }
    input:checked + .slider { background-color: #4CAF50; }
    input:checked + .slider:before { transform: translateX(26px); }
  </style>
</head>
<body>
  <h2>ESP32 - ESP-NOW Controller</h2>
)rawliteral";

  html += R"rawliteral(
  <div class="card">
    <h3>Board #1</h3>
    <div class="switch-container">
      <span class="switch-label">GPIO )rawliteral";
  html += String(board1Pin1);
  html += R"rawliteral(</span>
      <label class="switch">
        <input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,)rawliteral";
  html += String(board1Pin1);
  html += R"rawliteral(, this)">
        <span class="slider"></span>
      </label>
    </div>
    <div class="switch-container">
      <span class="switch-label">GPIO )rawliteral";
  html += String(board1Pin2);
  html += R"rawliteral(</span>
      <label class="switch">
        <input type="checkbox" id="b1Btn2" onchange="toggleSwitch(1,)rawliteral";
  html += String(board1Pin2);
  html += R"rawliteral(, this)">
        <span class="slider"></span>
      </label>
    </div>
  </div>
)rawliteral";

  html += R"rawliteral(
  <div class="card">
    <h3>Board #2</h3>
    <div class="switch-container">
      <span class="switch-label">GPIO )rawliteral";
  html += String(board2Pin1);
  html += R"rawliteral(</span>
      <label class="switch">
        <input type="checkbox" id="b2Btn1" onchange="toggleSwitch(2,)rawliteral";
  html += String(board2Pin1);
  html += R"rawliteral(, this)">
        <span class="slider"></span>
      </label>
    </div>
    <div class="switch-container">
      <span class="switch-label">GPIO )rawliteral";
  html += String(board2Pin2);
  html += R"rawliteral(</span>
      <label class="switch">
        <input type="checkbox" id="b2Btn2" onchange="toggleSwitch(2,)rawliteral";
  html += String(board2Pin2);
  html += R"rawliteral(, this)">
        <span class="slider"></span>
      </label>
    </div>
  </div>
)rawliteral";

  html += R"rawliteral(
  <script>
    function applyStates(states) {
      document.getElementById('b1Btn1').checked = states.b1Btn1 || false;
      document.getElementById('b1Btn2').checked = states.b1Btn2 || false;
      document.getElementById('b2Btn1').checked = states.b2Btn1 || false;
      document.getElementById('b2Btn2').checked = states.b2Btn2 || false;
    }

    async function loadStates() {
      try {
        const response = await fetch('/states');
        const states = await response.json();
        applyStates(states);
      } catch (err) {
        console.error("Failed to load states:", err);
      }
    }

    function toggleSwitch(board, gpio, element) {
      if (!element) {
        console.error("Element is undefined");
        return;
      }
      const state = element.checked;

      fetch(`/control?board=${board}&gpio=${gpio}&state=${state ? 1 : 0}`)
        .then(response => response.text())
        .then(data => console.log(data))
        .catch(err => {
          console.error(err);
          element.checked = !element.checked;
        });
    }

    window.onload = function() {
      loadStates();
      setInterval(loadStates, 5000);
    };
  </script>
</body>
</html>
)rawliteral";

  return html;
}

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

  // Set device as a Wi-Fi Station and establish a Wi-Fi connection
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, password);
  Serial.print("Connecting to Wi-Fi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWi-Fi Connected!");
  // Print the ESP32 IP Address
  Serial.print("Access ESP32 IP Address: http://");
  Serial.println(WiFi.localIP());

  // 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;
  }

  // Root URL handler
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(200, "text/html", renderHTML());
  });

  // LED Control via ESP-NOW
  server.on("/control", HTTP_GET, [](AsyncWebServerRequest *request){
    // Validate URL parameters
    if (request->hasParam("board") && request->hasParam("gpio") && request->hasParam("state")) {
      int board = request->getParam("board")->value().toInt();
      int gpio = request->getParam("gpio")->value().toInt();
      bool newState = request->getParam("state")->value().toInt() == 1;

      bool* statePtr = getStatePtr(board, gpio);
      if (statePtr == nullptr) {
        request->send(400, "text/plain", "Invalid board/gpio");
        return;
      }

      // Set up the pending update for delivery tracking
      pendingUpdate.active = true;
      pendingUpdate.board = board;
      pendingUpdate.gpio = gpio;
      pendingUpdate.newState = newState;
      pendingUpdate.oldState = *statePtr;

      struct_message msg;
      msg.gpio = gpio;
      msg.state = newState;

      uint8_t* target = (board == 1) ? board1Address : board2Address;
      esp_err_t result = sendEspNowMessage(target, msg);

      if (result != ESP_OK) {
        // If sending failed, revert the GPIO state immediately
        pendingUpdate.active = false;
        request->send(500, "text/plain", "Send failed (not transmitted)");
      } else {
        // If sending succeeded, wait for delivery confirmation in the callback
        request->send(200, "text/plain", "Sent, awaiting delivery confirmation");
      }
    } else {
      request->send(400, "text/plain", "Missing parameters");
    }
  });

  // Get confirmed states of each GPIO by board
  server.on("/states", HTTP_GET, [](AsyncWebServerRequest *request){
    // Return the confirmed states of each GPIO by board in JSON format
    String json = "{";
    json += "\"b1Btn1\":" + String(board1Btn1 ? "true" : "false") + ",";
    json += "\"b1Btn2\":" + String(board1Btn2 ? "true" : "false") + ",";
    json += "\"b2Btn1\":" + String(board2Btn1 ? "true" : "false") + ",";
    json += "\"b2Btn2\":" + String(board2Btn2 ? "true" : "false");
    json += "}";
    request->send(200, "application/json", json);
  });
  
  server.begin();
  Serial.println("ESP32 Web Server is Ready!");
}

void loop() {
  
}

View raw code

Перед загрузкой следующего кода на вашу плату-контроллер ESP32 вам нужно вставить ваш SSID и пароль, а также MAC-адреса плат-приёмников в следующие строки:

// REPLACE WITH YOUR WI-FI CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

// 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};

Как работает код?#

Продолжайте читать, чтобы узнать, как работает код, или перейдите к разделу демонстрации.

Подключение библиотек#

Подключите библиотеки для использования ESP-NOW и для построения веб-сервера.

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

Сетевые учётные данные#

Включите ваши сетевые учётные данные в следующие строки, чтобы ESP32 мог подключиться к вашему роутеру через Wi-Fi.

// REPLACE WITH YOUR WI-FI CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

MAC-адрес плат-приёмников#

Измените следующие строки, указав MAC-адрес ваших плат-приёмников. Например, в моём случае:

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

Итак, это будет выглядеть так:

// 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};

Структура данных для отправки данных#

Создайте структуру данных для отправки данных другим платам ESP32 для управления их GPIO. Это должна быть та же структура, что установлена в платах-приёмниках. Структура содержит GPIO для управления (int gpio) и состояние (bool state), либо ON (true), либо OFF (false).

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

Теперь создайте две переменные типа struct_message, по одной для каждой платы: board1Data и board2Data.

struct_message board1Data;
struct_message board2Data;

Подтверждение доставленных состояний#

Чтобы подтвердить, доставлено ли каждое состояние кнопки, мы создаём переменную для каждой кнопки на странице веб-сервера.

// Confirmed delivered states
bool board1Btn1 = false;
bool board1Btn2 = false;
bool board2Btn1 = false;
bool board2Btn2 = false;

Мы также создаём структуру, которая поможет нам отслеживать, было ли доставлено сообщение, каким GPIO мы хотим управлять, новое состояние GPIO и его старое состояние. Это поможет решить, изменять ли состояние GPIO в интерфейсе в зависимости от статуса доставки сообщения.

// Pending messages delivery tracking
struct PendingUpdate {
  bool active = false;
  int board;
  int gpio;
  bool newState;
  bool oldState;
};

Мы создаём новую переменную pendingUpdate типа PendingUpdate.

PendingUpdate pendingUpdate;

Глобальные переменные#

Мы создаём глобальные переменные для ссылки на GPIO, которыми хотим управлять. Мы будем управлять GPIO 2 и 4 с платы 1 и GPIO 20 и 21 с платы 2. Вы можете настроить управление любыми GPIO по вашему выбору.

// GPIO pin definitions
const int board1Pin1 = 2;
const int board1Pin2 = 4;
const int board2Pin1 = 20;
const int board2Pin2 = 21;

Создайте переменную типа esp_now_peer_info, которая будет использоваться для хранения данных о пирах ESP-NOW (платах-приёмниках).

esp_now_peer_info_t peerInfo;

Создайте экземпляр AsyncWebServer с именем server на порту 80, который ссылается на наш веб-сервер.

AsyncWebServer server(80);

Callback-функция OnDataSent()#

Callback-функция OnDataSent() будет выполняться при отправке сообщения ESP-NOW.

// ESP-NOW callback function when data is sent
void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  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("Packet to: ");
  Serial.print(macStr);
  Serial.print(" send status:\t");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");

  if (!pendingUpdate.active) return;

  // Ensure the pending update corresponds to the correct board and GPIO
  bool* statePtr = getStatePtr(pendingUpdate.board, pendingUpdate.gpio);
  if (statePtr == nullptr) {
    pendingUpdate.active = false;
    return;
  }

  // Commit or revert the state based on delivery status
  if (status == ESP_NOW_SEND_SUCCESS) {
    *statePtr = pendingUpdate.newState;
    Serial.printf("State committed: board=%d gpio=%d state=%d\n",
                  pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.newState);
  } else {
    *statePtr = pendingUpdate.oldState;
    Serial.printf("Delivery failed — state reverted: board=%d gpio=%d back to %d\n",
                  pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.oldState);
  }
  pendingUpdate.active = false;
}

Эта функция проверяет, было ли сообщение успешно доставлено, и выводит результат в Serial Monitor.

void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) {
  char macStr[18];
  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("Packet to: ");
  Serial.print(macStr);
  Serial.print(" send status:\t");
  Serial.println(status == ESP_NOW_SEND_SUCCESS ? "Delivery Success" : "Delivery Fail");

Затем она также проверяет, есть ли ожидающее изменение состояния. Это происходит, когда мы нажимаем переключатели и ждём подтверждения доставки.

if (!pendingUpdate.active) return;

Если сообщение успешно доставлено, мы фиксируем новое состояние. В противном случае мы возвращаемся к старому состоянию.

// Commit or revert the state based on delivery status
if (status == ESP_NOW_SEND_SUCCESS) {
  *statePtr = pendingUpdate.newState;
  Serial.printf("State committed: board=%d gpio=%d state=%d\n",
                pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.newState);
} else {
  *statePtr = pendingUpdate.oldState;
  Serial.printf("Delivery failed — state reverted: board=%d gpio=%d back to %d\n",
                pendingUpdate.board, pendingUpdate.gpio, pendingUpdate.oldState);
}

Наконец, мы устанавливаем переменную pendingUpdate в False, потому что мы уже подтвердили доставку.

pendingUpdate.active = false;

Отправка сообщения ESP-NOW#

Функция sendEspNowMessage() отправляет сообщение через ESP-NOW. Она принимает MAC-адрес платы-приёмника и сообщение для доставки в качестве аргументов. Она выводит и возвращает результат.

// Function to send ESP-NOW message
esp_err_t sendEspNowMessage(const uint8_t *boardMacAddress, const struct_message &message) {
  esp_err_t result = esp_now_send(boardMacAddress, (const uint8_t *) &message, sizeof(message));
  // Handle the result of the send operation
  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);
  }
  return result;
}

Рендеринг HTML-страницы#

Функция renderHTML() создаёт веб-страницу с HTML, CSS и JavaScript.

Веб-страница конкатенируется с GPIO, которыми мы хотим управлять (board1Pin1, board1Pin2, board2Pin1, board2Pin2). Это упрощает настройку веб-страницы для любых GPIO, которыми вы хотите управлять.

String renderHTML() {
  String html = R"rawliteral(
<!DOCTYPE HTML>
<html>
(...)
</html>
)rawliteral";

  return html;
}

Стилизация веб-страницы#

Сначала мы включаем CSS для стилизации веб-страницы между тегами <style> </style>.

<style>
  body { font-family: Arial; text-align: center; margin: 0; padding: 20px; background: #f4f4f4; }
  h2 { color: #333; }
  .card { background: white; padding: 20px; margin: 15px auto; max-width: 420px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
  .switch-container { margin: 15px 0; display: flex; align-items: center; justify-content: space-between; padding: 10px 0; }
  .switch-label { font-size: 18px; font-weight: 500; }
  .switch { position: relative; display: inline-block; width: 60px; height: 34px; }
  .switch input { opacity: 0; width: 0; height: 0; }
  .slider {
    position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
    background-color: #ccc; transition: .4s; border-radius: 34px;
  }
  .slider:before {
    position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 4px;
    background-color: white; transition: .4s; border-radius: 50%;
  }
  input:checked + .slider { background-color: #4CAF50; }
  input:checked + .slider:before { transform: translateX(26px); }
</style>

Переключатели#

Затем у нас есть HTML-раздел для создания карточки с двумя переключателями для платы 1.

  html += R"rawliteral(
  <div class="card">
    <h3>Board #1</h3>
    <div class="switch-container">
      <span class="switch-label">GPIO )rawliteral";
  html += String(board1Pin1);
  html += R"rawliteral(</span>
      <label class="switch">
        <input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,)rawliteral";
  html += String(board1Pin1);
  html += R"rawliteral(, this)">
        <span class="slider"></span>
      </label>
    </div>
    <div class="switch-container">
      <span class="switch-label">GPIO )rawliteral";
  html += String(board1Pin2);
  html += R"rawliteral(</span>
      <label class="switch">
        <input type="checkbox" id="b1Btn2" onchange="toggleSwitch(1,)rawliteral";
  html += String(board1Pin2);
  html += R"rawliteral(, this)">
        <span class="slider"></span>
      </label>
    </div>
  </div>
)rawliteral";

Обратите внимание, что для создания веб-страницы мы конкатенируем значение переменных board1Pin1 и board1Pin2 с остальной частью веб-страницы. Это делает веб-сервер более универсальным. Вам нужно только изменить значения переменных для управления другими GPIO.

Например, в нашем случае:

board1Pin1 = 2;

Итак, этот кусок кода:

<span class="switch-label">GPIO )rawliteral";
  html += String(board1Pin1);
  html += R"rawliteral(</span>

Становится:

<span class="switch-label">GPIO 2</span>

И этот раздел:

html += R"rawliteral(</span>
      <label class="switch">
        <input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,)rawliteral";
  html += String(board1Pin1);
  html += R"rawliteral(, this)">

Будет выглядеть так:

<input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,2, this)">

Аналогично для других переключателей.

Обработка переключателя#

Когда мы нажимаем на переключатели, они вызывают JavaScript-функцию toggleSwitch(). Они передают в качестве аргументов плату, к которой относится переключатель, GPIO и элемент переключателя (this) для идентификации того, какой переключатель был нажат.

<input type="checkbox" id="b1Btn1" onchange="toggleSwitch(1,2, this)">

Функция toggleSwitch() определена в разделе <script> </script> HTML.

function toggleSwitch(board, gpio, element) {
  if (!element) {
    console.error("Element is undefined");
    return;
  }
  const state = element.checked;

  fetch(`/control?board=${board}&gpio=${gpio}&state=${state ? 1 : 0}`)
    .then(response => response.text())
    .then(data => console.log(data))
    .catch(err => {
      console.error(err);
      element.checked = !element.checked;
    });
}

Например, если вы нажмёте на первый переключатель, board=1, gpio=2 и element = переключатель, который был нажат. Итак, он выполнит:

toggleSwitch(1, 2, this);

Он проверяет состояние переключателя:

const state = element.checked;

И затем мы делаем запрос с соответствующей платой, GPIO и состоянием:

fetch(`/control?board=${board}&gpio=${gpio}&state=${state ? 1 : 0}`)

Например, в случае первого переключателя, он сделает запрос вида:

/control?board=1&gpio=2&state=1

Когда ESP32 отправляет ответ, мы конвертируем его в текст и выводим в консоль JavaScript.

.then(response => response.text())
  .then(data => console.log(data))

В случае, если запрос не успешен (ESP32 не получил запрос), мы возвращаем переключатель в предыдущее состояние.

.catch(err => {
  console.error(err);
  element.checked = !element.checked;

Обновление состояний переключателей#

Когда страница впервые загружается, браузер не знает фактические состояния GPIO, хранящиеся на ESP32. Все чекбоксы могут изначально отображаться как неотмеченные (переключатели выключены).

Когда окно впервые загружается, мы вызываем функцию loadStates() для установки состояний переключателей в соответствии с состояниями GPIO.

window.onload = function() {
  loadStates();
  setInterval(loadStates, 5000);

Функция loadStates() также вызывается каждые пять секунд. Это делается для обновления веб-страницы в соответствии с состояниями GPIO (в случае, если сообщение не доставлено).

setInterval(loadStates, 5000);

Итак, что происходит: веб-страница загружается, и JavaScript вызывает функцию loadStates(), которая делает запрос на URL /states:

async function loadStates() {
  try {
    const response = await fetch('/states');
    const states = await response.json();
    applyStates(states);
  } catch (err) {
    console.error("Failed to load states:", err);
  }
}

ESP32 отвечает JSON, содержащим текущие состояния. Мы читаем эти значения (applyStates()):

const states = await response.json();
applyStates(states);

Свойство .checked каждого чекбокса обновляется, чтобы отражать текущие состояния GPIO. Веб-страница теперь отражает текущее состояние устройства со значениями переключателей, соответствующими состоянию, полученному ESP32.

function applyStates(states) {
  document.getElementById('b1Btn1').checked = states.b1Btn1 || false;
  document.getElementById('b1Btn2').checked = states.b1Btn2 || false;
  document.getElementById('b2Btn1').checked = states.b2Btn1 || false;
  document.getElementById('b2Btn2').checked = states.b2Btn2 || false;
}

setup()#

В setup() инициализируйте Serial Monitor для отладки.

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

Установите ESP32 как Wi-Fi station, подключите его к вашему роутеру и выведите IP-адрес ESP32.

// Set device as a Wi-Fi Station and establish a Wi-Fi connection
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
  delay(500);
  Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Print the ESP32 IP Address
Serial.print("Access ESP32 IP Address: http://");
Serial.println(WiFi.localIP());

Инициализируйте ESP-NOW.

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

Callback-функция ESP-NOW#

Зарегистрируйте callback-функцию отправки ESP-NOW. В данном случае это OnDataSent(), которую мы видели ранее.

esp_now_register_send_cb(esp_now_send_cb_t(OnDataSent));

Пиры ESP-NOW#

Зарегистрируйте платы-приёмники как пиры ESP-NOW.

// 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;
}

Обработка веб-сервера#

Теперь давайте обработаем, что происходит, когда вы обращаетесь к веб-серверу ESP32 в вашем браузере.

Когда вы обращаетесь к корневому URL / (введя IP-адрес ESP32 в веб-браузере), мы отправим отрендеренную HTML-страницу с текущими состояниями GPIO (функция renderHTML()).

// Root URL handler
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
  request->send(200, "text/html", renderHTML());
});

Следующие строки обрабатывают, что происходит, когда ESP32 получает запрос на URL /states. В этом случае мы подготавливаем JSON-переменную, содержащую текущее состояние каждой кнопки.

Например, если board1Btn1 равно true, возвращаемый JSON:

"b1Btn1":true

То же самое происходит для других переменных.

Когда JSON-переменная готова, мы отправляем ответ со строкой JSON.

request->send(200, "application/json", json);

Наконец, мы запускаем веб-сервер.

server.begin();

loop()#

Поскольку всё работает асинхронно с callback-функциями, нам не нужно ничего добавлять в loop().

void loop() {
  
}

Тестирование веб-сервера#

Загрузите код веб-сервера на плату-контроллер ESP32. После загрузки откройте Serial Monitor на скорости 115200 и нажмите кнопку RST на ESP32. Его IP-адрес будет напечатан.

Откройте веб-браузер в вашей локальной сети (на компьютере или смартфоне) и введите IP-адрес ESP32. Вы получите доступ к следующей веб-странице для управления GPIO других плат ESP32.

Используйте переключатели для управления GPIO других плат ESP32.

Если вы откроете окно Serial Monitor для каждой из ваших плат, вы увидите отправляемое сообщение и сообщение, доставленное на плату-приёмник.

Если сообщение не доставлено успешно, переключатель вернётся в предыдущее состояние.

Вы можете посмотреть следующую быструю видеодемонстрацию.

Подводя итог#

В этом руководстве вы построили веб-сервер ESP32, который управляет другими платами ESP32 с помощью ESP-NOW. Это демонстрирует, насколько универсален протокол ESP-NOW. На наш взгляд, это один из лучших протоколов связи для взаимодействия нескольких плат ESP32 друг с другом.

Вот другие связанные руководства, которые вам могут понравиться: