Инструкции

Веб-сервер на ESP32: панель управления в браузере

12 мин чтения·Август 2026·RoboArm R1

Веб-интерфейс — самый дешёвый способ дать устройству управление: клиент у пользователя уже установлен. Ни мобильного приложения, ни драйверов, ни объяснений про монитор порта — открыл адрес, нажал кнопку.

Задачи, где это оправдано:

  • настройка без перепрошивки — пороги, калибровки, имя сети;
  • ручное управление — подать команду приводу, включить реле, запустить цикл;
  • наблюдение — текущие показания датчиков и состояние устройства;
  • отладка на месте — счётчики и логи, когда кабель подключить некуда.

Ниже — рабочая панель с чекбоксом и бегунком, управляющая светодиодом и сервоприводом. Страница не перезагружается, а из внешних зависимостей нужна только библиотека сервопривода — всё остальное есть в пакете плат esp32 by Espressif Systems 3.x. Примеры рассчитаны на классический ESP32 (DevKit), про адаптацию под другие чипы — в конце.

Что делает встроенный WebServer

Библиотека WebServer входит в пакет плат. Она разбирает запрос, находит подходящий обработчик и отдаёт ответ — обычный синхронный роутер, только на плате.

#include <WiFi.h>
#include <WebServer.h>

WebServer server(80);

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

  WiFi.mode(WIFI_STA);
  WiFi.begin("MyNetwork", "MyPassword");
  while (WiFi.status() != WL_CONNECTED) {
    delay(300);
  }
  Serial.println(WiFi.localIP());

  server.on("/ping", HTTP_GET, [] {
    server.send(200, "text/plain", "pong");
  });

  server.begin();
}

void loop() {
  server.handleClient();   // разбирает не более одного запроса за вызов
}

handleClient() не блокирует: если запросов нет, он сразу возвращает управление. Отсюда главное следствие — сервер живёт ровно настолько, насколько быстро крутится loop(). Уйдёте в delay(5000) — на пять секунд устройство пропадёт из сети.

⚠️

В ядре 3.x у WebServer изменились заголовки: подключается NetworkClient.h, а не WiFiClient.h, как было в 2.x. Большинство руководств в интернете написаны под 2.x — если скетч не собирается на этой строке, дело в версии.

Панель управления

Панель — один HTML-файл со стилями внутри, а вся динамика — запросы к API из JavaScript: нажатие на чекбокс отправляет запрос, страница остаётся на месте.

Держим страницу в константе во flash, чтобы она не занимала ОЗУ:

static const char indexHtml[] PROGMEM = R"HTML(
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Панель управления</title>
<style>
  body { font: 16px/1.5 system-ui, sans-serif; max-width: 22rem;
         margin: 2rem auto; padding: 0 1rem; }
  fieldset { border: 1px solid #c8c8c8; border-radius: .5rem; margin: 0 0 1rem; }
  label { display: flex; align-items: center; gap: .75rem; }
  input[type=range] { flex: 1; }
  output { min-width: 3ch; text-align: right; font-variant-numeric: tabular-nums; }
  #status { min-height: 1.5em; font-size: .875rem; color: #666666; }
</style>
</head>
<body>
<h1>Панель управления</h1>

<fieldset>
  <legend>Светодиод</legend>
  <label><input type="checkbox" id="led"> включён</label>
</fieldset>

<fieldset>
  <legend>Сервопривод</legend>
  <label>
    <input type="range" id="angle" min="0" max="180" value="90">
    <output id="angleValue">90</output>
  </label>
</fieldset>

<p id="status"></p>

<script>
const statusLine = document.getElementById('status');
const led = document.getElementById('led');
const angle = document.getElementById('angle');
const angleValue = document.getElementById('angleValue');

async function send(path) {
  try {
    const response = await fetch(path, { method: 'POST' });
    statusLine.textContent = response.ok ? '' : 'ошибка ' + response.status;
  } catch {
    statusLine.textContent = 'нет связи с платой';
  }
}

led.addEventListener('change', () => send('/api/led?on=' + (led.checked ? 1 : 0)));

// input — только обновить подпись, без запроса
angle.addEventListener('input', () => { angleValue.textContent = angle.value; });
// change — запрос, когда бегунок отпустили
angle.addEventListener('change', () => send('/api/servo?angle=' + angle.value));

// подтягиваем текущее состояние, чтобы панель не открывалась «вслепую»
fetch('/api/state')
  .then((response) => response.json())
  .then((state) => {
    led.checked = state.led;
    angle.value = state.angle;
    angleValue.textContent = state.angle;
  });
</script>
</body>
</html>
)HTML";

Три момента, ради которых это написано именно так.

Разделение input и change у бегунка. Событие input срабатывает на каждый пиксель перетаскивания — это десятки запросов в секунду, которых синхронный сервер не переварит. Поэтому input только меняет подпись, а запрос уходит на change, то есть когда бегунок отпустили.

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

Стили и скрипт внутри того же файла. Отдельные style.css и app.js — это два дополнительных запроса, два обработчика и два файла, которые надо не забыть залить. Для панели на два элемента управления один файл проще во всех смыслах.

ℹ️

Панель использует только штатные элементы браузера и fetch, без сборки и фреймворков. Ориентир — браузеры на движке Chromium (Chrome, Edge, Arc, Яндекс), и для устройства в локальной сети этого достаточно. Если нужна гарантированная поддержка Safari и Firefox, проверьте отдельно: расхождения обычно не в JavaScript, а в оформлении input[type=range].

Обработчики на плате

Светодиодом управляет digitalWrite(), сервоприводом — библиотека ESP32Servo.

#include <WiFi.h>
#include <WebServer.h>
#include <ESP32Servo.h>

const uint8_t ledPin = 2;        // встроенный светодиод на большинстве DevKit
const uint8_t servoPin = 18;

WebServer server(80);
Servo servo;

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

  pinMode(ledPin, OUTPUT);
  servo.attach(servoPin);
  servo.write(90);

  WiFi.mode(WIFI_STA);
  WiFi.begin("MyNetwork", "MyPassword");
  while (WiFi.status() != WL_CONNECTED) {
    delay(300);
  }
  Serial.println(WiFi.localIP());

  server.on("/", HTTP_GET, [] {
    server.send_P(200, "text/html", indexHtml);
  });

  server.on("/api/led", HTTP_POST, [] {
    String value = server.arg("on");

    // Разрешаем ровно два значения — всё остальное отбиваем
    if (value != "0" && value != "1") {
      server.send(400, "text/plain", "on: ожидается 0 или 1");
      return;
    }

    digitalWrite(ledPin, value == "1" ? HIGH : LOW);
    server.send(204);
  });

  server.on("/api/servo", HTTP_POST, [] {
    long angle = server.arg("angle").toInt();

    // Диапазон проверяем обязательно: угол за пределами уведёт привод в упор
    if (angle < 0 || angle > 180) {
      server.send(400, "text/plain", "angle: ожидается 0…180");
      return;
    }

    servo.write(angle);
    server.send(204);
  });

  server.on("/api/state", HTTP_GET, [] {
    String body = String("{\"led\":") + (digitalRead(ledPin) ? "true" : "false")
                + ",\"angle\":" + servo.read() + "}";
    server.send(200, "application/json", body);
  });

  server.begin();
}

void loop() {
  server.handleClient();
}

Что здесь стоит заметить.

Команды — на POST, чтение — на GET. Не формальность: GET по правилам HTTP обязан быть безопасным, и браузер вправе повторить его сам — при обновлении страницы или предзагрузке ссылки.

server.arg() читает и строку запроса, и тело. В примере параметры едут в URL (/api/led?on=1), но если отправить их телом в формате application/x-www-form-urlencoded, тот же arg("on") их найдёт — библиотека склеивает оба источника.

Ответ 204 No Content. Команда выполнена, возвращать нечего. В JavaScript response.ok для 204 истинно, так что проверка работает.

Проверка значений — по минимуму, но есть. Чекбокс присылает ровно два значения, и всё остальное отбивается сравнением; у сервопривода проверяется диапазон. Этого хватает, чтобы кривой запрос не увёл привод в упор.

ℹ️

Проверка выше именно минимальная, и один пробел в ней виден невооружённым глазом: toInt() для строки abc вернёт 0, ноль попадёт в допустимый диапазон, и привод поедет в нулевое положение. Для панели, которой пользуетесь вы сами, это терпимо. Для API, доступного кому-то ещё, значение разбирают через strtol() и проверяют, что строка кончилась там, где ожидалось, — но валидация ввода тянет на отдельный разговор.

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

ℹ️

ESP32Servo — единственная внешняя зависимость примера, ставится через менеджер библиотек по названию ESP32Servo и работает на всех чипах семейства. Для типовых приводов вроде SG90 и SG92R настраивать её не нужно: достаточно передать в attach() номер вывода.

ℹ️

Собирать JSON строкой, как в /api/state, нормально для двух полей. Как только их станет больше или появится вложенность — берите ArduinoJson, иначе экранирование кавычек начнёт съедать время. Это отдельная тема, см. JSON на Arduino и ESP32.

Страница в коде или в файловой системе

В примере HTML лежит в константе PROGMEM: страница живёт во flash рядом с кодом, send_P() отдаёт её напрямую, ОЗУ не расходуется. Плюс — один файл прошивки, ничего не забудешь залить. Минус — правка вёрстки требует перекомпиляции, а редактировать HTML внутри строкового литерала неудобно.

Второй вариант — положить тот же файл в файловую систему:

#include <LittleFS.h>

void setup() {
  // …

  if (!LittleFS.begin()) {
    Serial.println("Файловая система не смонтирована");
    return;
  }

  server.serveStatic("/", LittleFS, "/index.html", "max-age=3600");

  // …
}

serveStatic() сам обслуживает запрос и добавляет заголовок кэширования, так что отдельный обработчик для / не нужен. Файл кладут в папку data рядом со скетчем и заливают отдельно от прошивки — как именно, разобрано в статье Хранение данных на ESP32.

Практический выбор: пока страница одна и меняется редко — держите её в коде. Как только вёрстку начинают править чаще прошивки, переносите в файловую систему.

Доступ по имени вместо IP

Адрес, выданный роутером, может смениться, а запоминать его неудобно. Протокол mDNS даёт устройству имя в локальной сети:

#include <ESPmDNS.h>

if (MDNS.begin("panel")) {
  MDNS.addService("http", "tcp", 80);
  Serial.println("Открывайте http://panel.local");
}

Вызов addService() не обязателен для доступа по имени, но благодаря ему устройство видно в сетевых сканерах и в списке устройств macOS.

Поддержка mDNS есть в macOS и Linux из коробки, в Windows — начиная с Windows 10. Часть роутеров и почти все гостевые сети multicast-трафик блокируют, поэтому запасной вариант с IP-адресом стоит оставлять всегда.

Какой сервер выбирать

Встроенный WebServer синхронный: он обслуживает одного клиента за раз внутри loop(), и пока выполняется обработчик, остальные запросы ждут. Для панели управления это не ограничение, а упрощение — нет ни задач, ни колбэков, ни гонок.

Альтернатива — асинхронный сервер на отдельной библиотеке. Он держит несколько соединений, умеет WebSocket и не зависит от скорости loop(), но тянет внешнюю зависимость и другой стиль кода.

Когда встроенного сервера перестаёт хватать? Когда нужен поток данных в реальном времени (график с обновлением десять раз в секунду), когда клиентов действительно несколько одновременно, или когда loop() по другим причинам нельзя держать быстрым.

Что брать, если нужен WebSocket? В ядре его нет. Обычный выбор — ESP32Async/ESPAsyncWebServer вместе с ESP32Async/AsyncTCP. Важно: исходный проект me-no-dev не поддерживается, а форки нельзя смешивать между собой — сборка упадёт.

А если панель на два-три элемента? Оставайтесь на встроенном. Асинхронный сервер здесь добавит зависимостей и способов ошибиться, не дав ничего взамен.

Опрос или push? Пока данные обновляются раз в секунду, обычный запрос по таймеру из JavaScript проще и надёжнее WebSocket. Переходить на постоянное соединение стоит, когда частота растёт или задержка начинает мешать.

Можно ли обойтись без роутера? Да: поднимите на плате точку доступа (WiFi.softAP()), и телефон подключится к ней напрямую. Обычная схема для первичной настройки — см. Подключение ESP32 к Wi-Fi.

Другие платы

Код переносится почти без изменений: WebServer, WiFi, ESPmDNS и ESP32Servo работают во всём семействе. Меняются только номера выводов и пара настроек среды.

ЧипВстроенный светодиодСвободный вывод под приводНа что обратить внимание
ESP32 (DevKit)обычный, GPIO 218Базовый вариант, всё как в примере
ESP32-S3адресный, RGB_BUILTIN4–7digitalWrite адресным не управляет
ESP32-C3адресный, RGB_BUILTIN3–7Выводов мало, GPIO 8 ещё и strapping
ESP32-C6адресный, RGB_BUILTIN0–7Как ESP32-C3, но выводов больше

Два практических замечания.

На ESP32-S3, ESP32-C3 и ESP32-C6 «встроенный светодиод» — адресный (WS2812), и digitalWrite() его не зажжёт. Вместо него rgbLedWrite(RGB_BUILTIN, r, g, b), где для выключения передают нули. Номер вывода у макроса разный не только между чипами, но и между ревизиями плат — поэтому в коде лучше писать RGB_BUILTIN, а не число. Совсем проще — повесить обычный светодиод на любой свободный вывод и оставить пример без изменений.

И если на этих платах в мониторе порта пусто, проверьте Tools → USB CDC On Boot: при выключенной настройке вывод уходит в UART0, а не в USB.

Какие выводы вообще свободны на конкретном чипе — в статье Пины ESP32.

Частые проблемы

Страница не открывается. Проверьте, что плата получила адрес и что компьютер в той же сети. Гостевые сети роутеров изолируют клиентов друг от друга — панель в них недоступна принципиально.

Сервер отвечает через раз. В loop() появилось что-то долгое: delay(), опрос медленного датчика, запись в файл. Пока обработчик или цикл не вернутся, сервер молчит.

Плата перезагружается при движении привода. Сервопривод в момент старта тянет сотни миллиампер, и питания от USB-разъёма платы не хватает — в логе будет Brownout detector was triggered. Питайте привод отдельным источником, объединив только землю.

Бегунок «залипает» или запросы теряются. Слушаете input вместо change: синхронный сервер не успевает обрабатывать десятки запросов в секунду.

Кириллица в интерфейсе превращается в мусор. Нужен <meta charset="utf-8"> в HTML, а сам файл должен быть сохранён в UTF-8.

Панель открывается, но кнопки не работают. Откройте консоль браузера: чаще всего там 404 — метод обработчика не совпадает с методом запроса. HTTP_POST на плате и method: 'POST' в fetch должны соответствовать друг другу.

Что дальше

Три темы, которые логично изучить после этой, — каждая тянет на отдельную статью:

  • Структурированный обмен. Собирать JSON строками быстро надоедает — ArduinoJson делает это типобезопасно.
  • Авторизация. server.authenticate() даёт базовую защиту в две строки, но разговор про пароли, HTTPS и подделку запросов заметно шире.
  • Долгие операции без блокировки сервера. Если обработчик должен запустить что-то на секунды, его выносят в отдельную задачу FreeRTOS и общаются с ней через очередь — см. Два ядра ESP32 и FreeRTOS.

Плюс соседние статьи по теме: Подключение ESP32 к Wi-Fi и Хранение данных на ESP32.

Рекомендуемые продукты

Связанные статьи