Руководство по созданию веб-сервера ESP32 с WebSocket

Как создать веб-сервер ESP32 с WebSocket в Arduino IDE

Как создать веб-сервер ESP32 с WebSocket в Arduino IDE

Допустим, нам нужно создать проект, в котором ESP32 используется для управления лампочкой через WiFi. Реализация достаточно проста: мы настроим ESP32 в режиме soft-AP или STA, что позволит ему отображать веб-страницу с состоянием выключателя — «вкл» или «выкл». Когда пользователь переключает выключатель в браузере, ESP32 получает HTTP-запрос. В ответ он изменяет состояние лампочки и отправляет ответ обратно в браузер. Именно это мы делали при создании простого веб-сервера ESP32, и это отлично работает.

Однако у этого решения есть небольшая проблема. Веб — это многопользовательская система, а значит, к одному веб-серверу может подключиться множество людей. Другими словами, сервер — это общий ресурс. Что произойдёт, если два пользователя одновременно попытаются включить или выключить лампочку? Интерфейсы двух браузеров рассинхронизируются и не будут точно отображать реальное состояние лампочки. По этой причине такое решение не подходит для подобной системы.

Вместо этого мы можем воспользоваться двунаправленными возможностями протокола связи WebSocket для создания отзывчивой системы, в которой нажатие на выключатель немедленно передаётся на сервер и транслируется всем подключённым браузерам, так что состояние выключателя всегда синхронизировано для всех пользователей.

Именно этому вы научитесь в данном руководстве: как создать веб-сервер на ESP32 с использованием протокола связи WebSocket. Для практической демонстрации мы создадим веб-страницу для управления встроенным светодиодом ESP32. Состояние светодиода будет отображаться на веб-странице. Когда кто-то переключает светодиод на странице, его состояние мгновенно обновляется во всех браузерах, подключённых к серверу.

Готовы? Давайте начнём! Но сначала,

Что такое WebSocket?

Несмотря на то что название «WebSocket» на первый взгляд может показаться непонятным, концепция, стоящая за ним, на самом деле довольно проста, и вы сможете получить базовое представление о ней в кратчайшие сроки.

WebSocket — это название протокола связи, который обеспечивает двунаправленную (а если быть точнее — полнодуплексную) связь между клиентом и веб-сервером. Проще говоря, WebSocket — это технология, которая позволяет и клиенту, и серверу установить соединение, через которое любая из сторон может отправлять сообщения другой в любое время.

Это отличается от обычного HTTP-соединения, при котором клиент инициирует запрос, сервер отправляет ответ, и затем соединение завершается. Фактически WebSocket — это совершенно другой протокол связи: когда клиент устанавливает соединение с сервером, обе стороны могут отправлять и получать данные. Так же как сервер слушает новые сообщения, все подключённые клиенты тоже активно слушают. В результате сервер может отправить данные конкретному клиенту или транслировать их всем клиентам без необходимости запроса. Кроме того, соединение остаётся активным до тех пор, пока клиент или сервер его не закроет, обеспечивая непрерывную связь между ними.

Сравнение протоколов HTTP и WebSocket

Итак, WebSocket — это замечательная технология, но это не значит, что её нужно использовать повсюду. Реализация WebSocket может усложнить ваш проект, особенно на стороне сервера. Поэтому не стоит использовать её, если ваш проект не предполагает широковещательной передачи данных (когда вы хотите одновременно передать одни и те же данные нескольким клиентам); в противном случае опроса (polling) может быть достаточно.

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

Давайте теперь создадим простой проект, в котором мы построим WebSocket-сервер на ESP32 для удалённого управления встроенным светодиодом ESP32 через WebSocket-соединение.

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

ESP32 будет активно прослушивать порт 80 для входящих WebSocket-соединений и сообщений. Когда пользователь переключает светодиод на веб-странице, на ESP32 отправляется сообщение «toggle». Когда ESP32 получает это сообщение, он переключает светодиод и немедленно уведомляет все подключённые клиенты (браузеры), отправляя «1» (светодиод включён) или «0» (светодиод выключен). В результате все активные клиенты мгновенно обновляют статус светодиода на своих веб-страницах.

Для лучшей иллюстрации работы этого проекта мы включили анимацию ниже, которая показывает, как несколько клиентов подключаются к ESP32 через WebSocket-соединение и обмениваются сообщениями.

Обзор проекта WebSocket-сервера ESP32

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

Настройка Arduino IDE

Мы будем использовать Arduino IDE для программирования ESP32, поэтому убедитесь, что у вас установлено дополнение ESP32, прежде чем продолжить:

Установка платы ESP32 в Arduino IDE

Микроконтроллер ESP32 быстро стал одной из самых популярных плат среди любителей, инженеров и людей, интересующихся Интернетом вещей (IoT)…

/lastminuteengineers/esp32-arduino-ide-tutorial/index

Для создания WebSocket-сервера мы будем использовать библиотеку ESPAsyncWebServer. Для работы этой библиотеки требуется библиотека AsyncTCP. К сожалению, ни одна из этих библиотек недоступна в менеджере библиотек Arduino IDE для установки. Поэтому вам необходимо установить их вручную.

Нажмите на ссылки ниже, чтобы скачать библиотеки.

После загрузки в Arduino IDE перейдите в Sketch > Include Library > Add .ZIP Library и выберите только что загруженные библиотеки.

Загрузка кода

Скопируйте приведённый ниже код в вашу Arduino IDE. Но перед загрузкой кода вам необходимо внести одно важное изменение. Обновите следующие две переменные, указав учётные данные вашей сети, чтобы ESP32 мог подключиться к ней.

// Replace with your network credentials
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

После внесения этих изменений загрузите код.

// Import required libraries
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>

// Replace with your network credentials
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

bool ledState = 0;
const int ledPin = 2;

// Create AsyncWebServer object on port 80
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");

const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>ESP32 WebSocket Server</title>
    <style>
    html{font-family: Helvetica; display: inline-block; margin: 0px auto; text-align: center;}
    body{margin-top: 50px;}
    h1{color: #444444;margin: 50px auto;}
    p{font-size: 19px;color: #888;}
    #state{font-weight: bold;color: #444;}
    .switch{margin:25px auto;width:80px}
    .toggle{display:none}
    .toggle+label{display:block;position:relative;cursor:pointer;outline:0;user-select:none;padding:2px;width:80px;height:40px;background-color:#ddd;border-radius:40px}
    .toggle+label:before,.toggle+label:after{display:block;position:absolute;top:1px;left:1px;bottom:1px;content:""}
    .toggle+label:before{right:1px;background-color:#f1f1f1;border-radius:40px;transition:background .4s}
    .toggle+label:after{width:40px;background-color:#fff;border-radius:20px;box-shadow:0 2px 5px rgba(0,0,0,.3);transition:margin .4s}
    .toggle:checked+label:before{background-color:#4285f4}
    .toggle:checked+label:after{margin-left:42px}
    </style>
  </head>
  <body>
    <h1>ESP32 WebSocket Server</h1>
    <div class="switch">
      <input id="toggle-btn" class="toggle" type="checkbox" %CHECK%>
      <label for="toggle-btn"></label>
    </div>
    <p>On-board LED: <span id="state">%STATE%</span></p>

    <script>
       window.addEventListener('load', function() {
             var websocket = new WebSocket(`ws://${window.location.hostname}/ws`);
             websocket.onopen = function(event) {
               console.log('Connection established');
             }
             websocket.onclose = function(event) {
               console.log('Connection died');
             }
             websocket.onerror = function(error) {
               console.log('error');
             };
             websocket.onmessage = function(event) {
               if (event.data == "1") {
                     document.getElementById('state').innerHTML = "ON";
                     document.getElementById('toggle-btn').checked = true;
               }
               else {
                     document.getElementById('state').innerHTML = "OFF";
                     document.getElementById('toggle-btn').checked = false;
               }
             };

             document.getElementById('toggle-btn').addEventListener('change', function() { websocket.send('toggle'); });
       });
     </script>
  </body>
</html>
)rawliteral";

void handleWebSocketMessage(void *arg, uint8_t *data, size_t len) {
  AwsFrameInfo *info = (AwsFrameInfo*)arg;
  if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
    data[len] = 0;
    if (strcmp((char*)data, "toggle") == 0) {
      ledState = !ledState;
      ws.textAll(String(ledState));
    }
  }
}

void eventHandler(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) {
  switch (type) {
    case WS_EVT_CONNECT:
      Serial.printf("WebSocket client #%u connected from %s\n", client->id(), client->remoteIP().toString().c_str());
      break;
    case WS_EVT_DISCONNECT:
      Serial.printf("WebSocket client #%u disconnected\n", client->id());
      break;
    case WS_EVT_DATA:
      handleWebSocketMessage(arg, data, len);
      digitalWrite(ledPin, ledState);
      break;
    case WS_EVT_PONG:
    case WS_EVT_ERROR:
      break;
  }
}

String processor(const String& var){
  if(var == "STATE"){
      return ledState ? "ON" : "OFF";
  }
  if(var == "CHECK"){
    return ledState ? "checked" : "";
  }
  return String();
}

void setup(){
  // Serial port for debugging purposes
  Serial.begin(115200);

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

  Serial.print("Connecting to ");
  Serial.println(ssid);

  // Connect to Wi-Fi
  WiFi.begin(ssid, password);

  while (WiFi.status() != WL_CONNECTED) {
    delay(1000);
    Serial.print(".");
  }

  Serial.println("");
  Serial.println("Connected..!");
  Serial.print("Got IP: ");  Serial.println(WiFi.localIP());

  ws.onEvent(eventHandler);
  server.addHandler(&ws);

  // Route for root / web page
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send_P(200, "text/html", index_html, processor);
  });

  // Start server
  server.begin();
}

void loop() {
  ws.cleanupClients();
}

Демонстрация

После загрузки кода откройте монитор порта (Serial Monitor) и установите скорость передачи 115200 бод. Затем нажмите кнопку EN на ESP32. Подключение к сети может занять несколько секунд, после чего на экране отобразится динамический IP-адрес, полученный от вашего маршрутизатора.

Сообщение о запуске WebSocket-сервера ESP32

Затем откройте веб-браузер и перейдите по IP-адресу, отображённому в мониторе порта. ESP32 должен отобразить веб-страницу с текущим состоянием встроенного светодиода и переключателем для управления им.

Интерактивное веб-приложение ESP32 WebSocket

Теперь возьмите телефон или планшет и перейдите по тому же IP-адресу, убедившись, что устройство подключено к той же сети, что и ваш компьютер. Нажмите на переключатель, чтобы переключить светодиод. Вы заметите, что не только встроенный светодиод переключается, но и состояние светодиода автоматически обновляется во всех веб-браузерах.

Одновременно вы можете следить за монитором порта, чтобы видеть, какие клиенты подключаются к ESP32 и отключаются от него.

Список подключённых клиентов и IP-адресов ESP32 WebSocket

Подробное объяснение кода

Давайте разберём этот код.

Импорт необходимых библиотек

Скетч начинается с подключения следующих библиотек:

  • WiFi.h: предоставляет специфичные для ESP32 WiFi-методы, которые мы используем для подключения к сети.

  • ESPAsyncWebServer.h: используется для создания HTTP-сервера, поддерживающего конечные точки WebSocket.

  • AsyncTCP.h: библиотека ESPAsyncWebServer зависит от этой библиотеки.

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

Определение констант и переменных

Далее указываются учётные данные сети (SSID и пароль), которые вы должны заменить на данные своей WiFi-сети.

const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";

В той же глобальной области определены две переменные: одна для отслеживания текущего состояния GPIO (ledState) и другая для указания вывода GPIO, подключённого к светодиоду (ledPin). В данном случае мы будем управлять встроенным светодиодом (который подключён к GPIO 2).

bool ledState = 0;
const int ledPin = 2;

Инициализация веб-сервера и WebSocket

Далее создаётся объект класса AsyncWebServer с именем server. Конструктор этого класса принимает в качестве аргумента номер порта, на котором HTTP-сервер будет прослушивать входящие запросы. В данном случае мы используем стандартный HTTP-порт — порт 80.

AsyncWebServer server(80);

Затем для настройки конечной точки WebSocket создаётся объект класса AsyncWebSocket с именем ws. Путь конечной точки WebSocket должен быть передан в качестве аргумента конструктору этого класса. Этот путь определяется как строка, и в нашем коде он установлен как /ws.

Таким образом, WebSocket будет прослушивать соединения по пути: ws://[esp ip]/ws

AsyncWebSocket ws("/ws");

Создание веб-страницы

Затем определяется константа index_html. Она содержит необработанный строковый литерал (raw string literal) с HTML-кодом для отображения переключателя на веб-странице для управления встроенным светодиодом ESP32, а также CSS для стилизации веб-страницы и JavaScript для установки WebSocket-соединения и отслеживания изменений состояния светодиода. Весь этот код будет выполняться на стороне клиента.

Обратите внимание, что используется макрос PROGMEM, который сохраняет содержимое в программной памяти ESP32 (флеш-памяти), а не в SRAM, для оптимизации использования памяти.

const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>ESP32 WebSocket Server</title>
    <style>
    html{font-family: Helvetica; display: inline-block; margin: 0px auto; text-align: center;}
    body{margin-top: 50px;}
    h1{color: #444444;margin: 50px auto;}
    p{font-size: 19px;color: #888;}
    #state{font-weight: bold;color: #444;}
    .switch{margin:25px auto;width:80px}
    .toggle{display:none}
    .toggle+label{display:block;position:relative;cursor:pointer;outline:0;user-select:none;padding:2px;width:80px;height:40px;background-color:#ddd;border-radius:40px}
    .toggle+label:before,.toggle+label:after{display:block;position:absolute;top:1px;left:1px;bottom:1px;content:""}
    .toggle+label:before{right:1px;background-color:#f1f1f1;border-radius:40px;transition:background .4s}
    .toggle+label:after{width:40px;background-color:#fff;border-radius:20px;box-shadow:0 2px 5px rgba(0,0,0,.3);transition:margin .4s}
    .toggle:checked+label:before{background-color:#4285f4}
    .toggle:checked+label:after{margin-left:42px}
    </style>
  </head>
  <body>
    <h1>ESP32 WebSocket Server</h1>
    <div class="switch">
      <input id="toggle-btn" class="toggle" type="checkbox" %CHECK%>
      <label for="toggle-btn"></label>
    </div>
    <p>On-board LED: <span id="state">%STATE%</span></p>

    <script>
      window.addEventListener('load', function() {
        var websocket = new WebSocket(`ws://${window.location.hostname}/ws`);
        websocket.onmessage = function () {
          if (event.data == "1") {
            document.getElementById('state').innerHTML = "ON";
            document.getElementById('toggle-btn').checked = true;
          }
          else {
            document.getElementById('state').innerHTML = "OFF";
            document.getElementById('toggle-btn').checked = false;
          }
        };
        document.getElementById('toggle-btn').addEventListener('change', function() { websocket.send('toggle'); });
      });
    </script>
  </body>
</html>
)rawliteral";

CSS

Между тегами <style></style> находится CSS-код, используемый для стилизации веб-страницы. Вы можете изменить его, чтобы веб-сайт выглядел так, как вам нужно.

<style>
html{font-family: Helvetica; display: inline-block; margin: 0px auto; text-align: center;}
body{margin-top: 50px;}
h1{color: #444444;margin: 50px auto;}
p{font-size: 19px;color: #888;}
#state{font-weight: bold;color: #444;}
.switch{margin:25px auto;width:80px}
.toggle{display:none}
.toggle+label{display:block;position:relative;cursor:pointer;outline:0;user-select:none;padding:2px;width:80px;height:40px;background-color:#ddd;border-radius:40px}
.toggle+label:before,.toggle+label:after{display:block;position:absolute;top:1px;left:1px;bottom:1px;content:""}
.toggle+label:before{right:1px;background-color:#f1f1f1;border-radius:40px;transition:background .4s}
.toggle+label:after{width:40px;background-color:#fff;border-radius:20px;box-shadow:0 2px 5px rgba(0,0,0,.3);transition:margin .4s}
.toggle:checked+label:before{background-color:#4285f4}
.toggle:checked+label:after{margin-left:42px}
</style>

HTML

Между тегами <body></body> находится фактическое содержимое веб-страницы.

<h1>ESP32 WebSocket Server</h1>
<div class="switch">
  <input id="toggle-btn" class="toggle" type="checkbox" %CHECK%>
  <label for="toggle-btn"></label>
</div>
<p>On-board LED: <span id="state">%STATE%</span></p>

Первая строка кода устанавливает заголовок страницы с помощью тега <h1>; вы можете изменить этот текст на любой, подходящий для вашего приложения.

<h1>ESP32 WebSocket Server</h1>

Следующий блок кода создаёт переключатель (toggle switch).

На самом деле это чекбокс (checkbox) со стилизацией, которая делает его похожим на переключатель.

<div class="switch">
  <input id="toggle-btn" class="toggle" type="checkbox" %CHECK%>
  <label for="toggle-btn"></label>
</div>

Наконец, есть параграф, отображающий состояние встроенного светодиода. Фактическое состояние светодиода («ON» или «OFF») отображается внутри элемента <span>.

<p>On-board LED: <span id="state">%STATE%</span></p>

Заполнители %CHECK% и %STATE% — это, по сути, переменные, которые ESP32 заменит соответствующими значениями при отправке веб-страницы, основываясь на текущем состоянии светодиода. Заполнители обеспечивают синхронизацию состояния светодиода между всеми пользователями. Если один пользователь изменит состояние светодиода, следующий пользователь, подключившийся к ESP32, должен увидеть обновлённое состояние светодиода, верно?

Заполнитель %STATE% будет заменён соответствующим текстом (либо «ON», либо «OFF») в зависимости от текущего состояния светодиода. А заполнитель %CHECK% определяет, должен ли чекбокс быть отмечен или нет (переключатель включён или выключен) при загрузке страницы.

Примечание

Обратите внимание, что заполнители вступают в действие, когда клиент подключается к ESP32 в самый первый раз. После установления WebSocket-соединения JavaScript берёт на себя все обновления. Вот почему каждый элемент с заполнителем имеет атрибут id. Атрибут id даёт каждому элементу уникальный идентификатор. Это позволяет JavaScript обращаться к этим элементам и манипулировать ими всякий раз, когда WebSocket получает сообщение от сервера.

JavaScript

Код JavaScript размещается между тегами <script></script>. Он отвечает за инициализацию WebSocket-соединения с сервером, за управление обменом данными через WebSocket, а также за соответствующее обновление всех элементов веб-страницы.

<script>
  window.addEventListener('load', function() {
     var websocket = new WebSocket(`ws://${window.location.hostname}/ws`);
     websocket.onopen = function(event) {
       console.log('Connection established');
     }
     websocket.onclose = function(event) {
       console.log('Connection died');
     }
     websocket.onerror = function(error) {
       console.log('error');
     };
     websocket.onmessage = function(event) {
       if (event.data == "1") {
             document.getElementById('state').innerHTML = "ON";
             document.getElementById('toggle-btn').checked = true;
       }
       else {
             document.getElementById('state').innerHTML = "OFF";
             document.getElementById('toggle-btn').checked = false;
       }
     };

     document.getElementById('toggle-btn').addEventListener('change', function() { websocket.send('toggle'); });
  });
</script>

Сначала к объекту window прикрепляется обработчик событий, чтобы гарантировать, что весь JavaScript-код выполнится только после полной загрузки страницы.

window.addEventListener('load', function() {

При загрузке устанавливается новое WebSocket-соединение с сервером, используя текущее имя хоста (IP-адрес ESP32) и специальный протокол ws:// в URL. К сведению, существует также зашифрованный протокол wss://; это как HTTPS для WebSocket.

var websocket = new WebSocket(`ws://${window.location.hostname}/ws`);

После установления соединения на экземпляре WebSocket на стороне клиента генерируется несколько событий. И мы должны прослушивать эти события. Всего существует четыре события:

  • onopen – срабатывает, когда соединение с WebSocket установлено.

  • onclose – срабатывает, когда соединение с WebSocket закрыто.

  • onerror – срабатывает, когда соединение с WebSocket было закрыто из-за ошибки, например, когда не удалось отправить данные.

  • onmessage – срабатывает, когда WebSocket получает данные от сервера.

Когда соединение открывается, закрывается или возникает ошибка, мы просто выводим сообщение в веб-консоль. Однако вы можете использовать эти события для добавления дополнительной функциональности при необходимости.

websocket.onopen = function(event) {
  console.log('Connection established');
}
websocket.onclose = function(event) {
  console.log('Connection died');
}
websocket.onerror = function(error) {
  console.log('error');
};

Теперь нам нужно обработать ситуацию, когда WebSocket получает данные от сервера, то есть когда срабатывает событие onmessage. Сервер (ваш ESP32) будет транслировать сообщение «1» или «0» в зависимости от текущего состояния светодиода. На основе полученного сообщения нам нужно предпринять соответствующее действие.

  • Если получен «1»: текст внутри элемента с ID „state“ должен быть установлен в «ON», а чекбокс с ID „toggle-btn“ должен быть отмечен как checked.

  • Если получен «0»: текст внутри элемента с ID „state“ должен быть установлен в «OFF», а чекбокс с ID „toggle-btn“ должен оставаться неотмеченным.

websocket.onmessage = function(event) {
  if (event.data == "1") {
     document.getElementById('state').innerHTML = "ON";
     document.getElementById('toggle-btn').checked = true;
  }
  else {
     document.getElementById('state').innerHTML = "OFF";
     document.getElementById('toggle-btn').checked = false;
  }
};

Наконец, к нашему переключателю прикрепляется обработчик событий. Когда переключатель включается или выключается (то есть когда изменяется состояние чекбокса „toggle-btn“), через WebSocket-соединение отправляется сообщение «toggle», информирующее ESP32 о необходимости переключить светодиод.

document.getElementById('toggle-btn').addEventListener('change', function() { websocket.send('toggle'); });

Функция setup()

До этого момента вы видели, как обрабатывать WebSocket-соединение на стороне клиента (браузера). Давайте теперь посмотрим, как обработать его на стороне сервера.

В функции setup() монитор порта инициализируется для целей отладки.

Serial.begin(115200);

Затем вывод светодиода настраивается как OUTPUT и устанавливается в состояние LOW.

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

После этого ESP32 пытается подключиться к WiFi-сети. Прогресс подключения и полученный IP-адрес выводятся в монитор порта.

Serial.print("Connecting to ");
Serial.println(ssid);

// Connect to Wi-Fi
WiFi.begin(ssid, password);

while (WiFi.status() != WL_CONNECTED) {
  delay(1000);
  Serial.print(".");
}

Serial.println("");
Serial.println("Connected..!");
Serial.print("Got IP: ");  Serial.println(WiFi.localIP());

Следующая строка настраивает обработчик событий для WebSocket. Это означает, что при возникновении любого события, связанного с WebSocket (объект ws), будет вызвана функция eventHandler() для обработки этого события. Функция eventHandler() содержит логику для определения типа произошедшего WebSocket-события (например, подключение нового клиента, получение данных, отключение клиента и т.д.) и способа реакции на это событие. Мы рассмотрим это позже.

ws.onEvent(eventHandler);

Следующая строка присоединяет или регистрирует WebSocket (объект ws) на веб-сервере (объект server). Это сообщает server, что у него есть обработчик WebSocket, и любые входящие WebSocket-соединения или запросы должны направляться к этому обработчику. &ws — это указатель на объект WebSocket, позволяющий server знать, куда отправлять и откуда получать данные, связанные с WebSocket.

server.addHandler(&ws);

Следующая строка устанавливает конечную точку (endpoint) или маршрут (route) на веб-сервере. В данном случае определяется корневой путь (»/»). Когда клиент отправляет запрос HTTP_GET на этот корневой путь, выполняется лямбда-функция (внутри фигурных скобок { … }).

Внутри этой функции вызывается request->send_P(...). Эта функция отправляет ответ клиенту.

  • 200 — это код состояния HTTP, указывающий на успешный запрос.

  • "text/html" сообщает клиенту, что сервер отправляет обратно HTML-содержимое.

  • index_html — это содержимое для отправки, то есть ваша HTML-веб-страница, хранящаяся в программной памяти. Подробнее об этом позже.

  • processor — это функция, которая обрабатывает HTML-содержимое перед отправкой (например, для замены заполнителей). Подробнее об этом позже.

server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
  request->send_P(200, "text/html", index_html, processor);
});

Наконец, веб-сервер запускается. После выполнения этой команды сервер начинает прослушивать входящие клиентские запросы на ранее определённых маршрутах (таких как корневой путь, который мы настроили).

server.begin();

Функция loop()

В случае синхронного веб-сервера вы обычно вызываете server.handleClient(); внутри цикла loop, который проверяет наличие клиентских запросов для обработки. При наличии таковых код должен обработать эти запросы и отправить ответы, прежде чем вернуть управление остальной части цикла.

В случае асинхронного веб-сервера ничего в цикле loop не требуется. AsyncWebServer настраивается с обработчиками событий и также использует библиотеку AsyncTCP. Благодаря этому он может активно прослушивать соединения в фоновом режиме, разбирать входящие запросы и вызывать соответствующий обработчик событий. Вот почему при создании асинхронного веб-сервера функция loop() обычно остаётся пустой.

Однако в нашем случае вызывается метод cleanupClients(). Этот метод является встроенным методом класса AsyncWebSocket. При работе с WebSocket клиенты могут часто подключаться и отключаться, или их соединения могут неожиданно обрываться. Со временем у вас могут накопиться WebSocket-клиенты, которые больше не активны, но всё ещё занимают ресурсы. В конечном итоге это исчерпает ресурсы веб-сервера и приведёт к его сбою.

Метод cleanupClients() перебирает все подключённые WebSocket-клиенты и удаляет те, которые больше не активны или не подключены. Периодический вызов функции cleanClients() из основного цикла loop() гарантирует, что ваш ESP32 не будет перегружен старыми, неиспользуемыми клиентскими соединениями и останется эффективным при обработке новых или активных клиентов.

void loop() {
  ws.cleanupClients();
}

Обработка серверных событий WebSocket

Если вы помните, в функции setup() мы настроили обработчик событий для WebSocket с помощью метода ws.onEvent(eventHandler), так что при возникновении любого события, связанного с WebSocket (объект ws), вызывается функция eventHandler() для его обработки.

Функция eventHandler() просто проверяет аргумент type для определения типа произошедшего события и реагирует на него. Аргумент type представляет произошедшее событие. Он может принимать следующие значения:

  • WS_EVT_CONNECT – когда новый клиент подключается к WebSocket-серверу

  • WS_EVT_DISCONNECT – когда клиент отключается от WebSocket-сервера

  • WS_EVT_DATA – когда WebSocket-сервер получает данные от клиента

  • WS_EVT_PONG – когда WebSocket получает запрос ping

  • WS_EVT_ERROR – когда клиент сообщает об ошибке

void eventHandler(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) {
  switch (type) {
    case WS_EVT_CONNECT:
      Serial.printf("WebSocket client #%u connected from %s\n", client->id(), client->remoteIP().toString().c_str());
      break;
    case WS_EVT_DISCONNECT:
      Serial.printf("WebSocket client #%u disconnected\n", client->id());
      break;
    case WS_EVT_DATA:
      handleWebSocketMessage(arg, data, len);
      digitalWrite(ledPin, ledState);
      break;
    case WS_EVT_PONG:
    case WS_EVT_ERROR:
      break;
  }
}

Как видите, когда тип события WS_EVT_CONNECT или WS_EVT_DISCONNECT, монитор порта отображает IP-адрес и уникальный идентификатор клиентов при их подключении или отключении. При этом для типов событий WS_EVT_PONG и WS_EVT_ERROR никаких действий не выполняется, хотя их можно при необходимости доработать.

Самый важный тип события — WS_EVT_DATA (когда WebSocket-сервер получает данные от клиента): полученные данные передаются в функцию handleWebSocketMessage() для дальнейшей обработки, и состояние светодиода обновляется последним значением ledState.

Чтение сообщений WebSocket и широковещательная рассылка

Функция handleWebSocketMessage() определена для обработки входящих WebSocket-сообщений. Когда получено корректное сообщение «toggle», эта функция переключает светодиод и уведомляет все подключённые клиенты об этом изменении с помощью метода textAll().

Метод textAll() класса AsyncWebSocket позволяет ESP32 одновременно отправить одно и то же сообщение всем подключённым клиентам, обеспечивая синхронизацию состояния светодиода между всеми клиентами.

void handleWebSocketMessage(void *arg, uint8_t *data, size_t len) {
  AwsFrameInfo *info = (AwsFrameInfo*)arg;
  if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT) {
    data[len] = 0;
    if (strcmp((char*)data, "toggle") == 0) {
      ledState = !ledState;
      ws.textAll(String(ledState));
    }
  }
}

Обработка заполнителей веб-страницы

Если вы помните, наша веб-страница имеет два заполнителя: %CHECK% и %STATE%. Функция processor() отвечает за поиск таких заполнителей в HTML-тексте и замену их соответствующими значениями перед отправкой веб-страницы в браузер.

Заполнитель %STATE% будет заменён либо на «ON», либо на «OFF» в зависимости от текущего состояния светодиода. А заполнитель %CHECK% определяет, должен ли чекбокс быть отмечен или нет (переключатель включён или выключен) при загрузке страницы.

String processor(const String& var){
  if(var == "STATE"){
      return ledState ? "ON" : "OFF";
  }
  if(var == "CHECK"){
    return ledState ? "checked" : "";
  }
  return String();
}