Веб-сервер на ESP32: панель управления в браузере
Веб-интерфейс — самый дешёвый способ дать устройству управление: клиент у пользователя уже установлен. Ни мобильного приложения, ни драйверов, ни объяснений про монитор порта — открыл адрес, нажал кнопку.
Задачи, где это оправдано:
- настройка без перепрошивки — пороги, калибровки, имя сети;
- ручное управление — подать команду приводу, включить реле, запустить цикл;
- наблюдение — текущие показания датчиков и состояние устройства;
- отладка на месте — счётчики и логи, когда кабель подключить некуда.
Ниже — рабочая панель с чекбоксом и бегунком, управляющая светодиодом и сервоприводом. Страница не перезагружается, а из внешних зависимостей нужна только библиотека сервопривода — всё остальное есть в пакете плат 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 2 | 18 | Базовый вариант, всё как в примере |
| ESP32-S3 | адресный, RGB_BUILTIN | 4–7 | digitalWrite адресным не управляет |
| ESP32-C3 | адресный, RGB_BUILTIN | 3–7 | Выводов мало, GPIO 8 ещё и strapping |
| ESP32-C6 | адресный, RGB_BUILTIN | 0–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.