Гирлянда на балконе два года жила на сыром WiFiServer из базового урока про мини-сервер: прочитать запрос-строку, найти indexOf("GET /on"), собрать ответ построчно. Работало, но каждая новая команда добавляла ещё один if и очередную копипасту заголовков ответа; к четвёртой ручке — свет, полив, подсветка аквариума, температура — скетч распух настолько, что я сам путался, какая ветка за что отвечает. Тогда я переехал на WebServer.h: класс, который входит в пакет плат ESP32 и держит пару «маршрут — обработчик» в одной строке. Экономия не косметическая: обработчик не парсит запрос руками, не собирает заголовки и не думает про 404 — класс делает рутину, мне остаётся бизнес-логика. Из четырёх маршрутов в скетче ниже ни один не знает про заголовки HTTP — это ровно тот уровень абстракции, которого мне не хватало.
Разбор сырого HTTP — то, что браузер реально шлёт плате, — остаётся в уроке про первый HTML из скетча, маршруты с параметрами вроде /set?bright=120 — отдельная история в уроке про маршруты и обработчики. Здесь минимум, с которого я советую начинать любой пульт: три эндпоинта — корень, /led/on, /led/off, страница с кнопками и одна дисциплина на весь проект — handleClient() в каждом проходе loop(). На этом минимуме у меня держится половина плат с веб-мордой, и он же — фундамент для всего остального.
Про скорость, чтобы вы понимали бюджет. Секундомер из millis() вокруг server.send() показывал на моём DevKit 6-12 мс на страницу-пульт и до 40-50 мс, когда WiFi-стек был параллельно занят держанием связи. Клик по кнопке в браузере телефона отрабатывает быстрее моргания: это локальный HTTP без выхода за роутер, и ни один облачный сервис такой отзывчивости не даст. Если у вас ответ приходит через секунды — где-то в loop() притаился delay, и это уже сюжет грабли ниже. Замер я держу прямо в обработчике на время отладки: пять строк, а правда на экране, а не в ощущениях.
Три ручки: корень, /led/on, /led/off
Маршрут в WebServer.h — это вызов server.on(путь, функция): пришли по /led/on — выполнится vkl(), пришли по /led/off — vykl(). Внутри обработчика ровно два дела: перевернуть состояние платы и ответить — server.send(200, "text/html", stranica()). Первый аргумент — код статуса, второй — MIME-тип, третий — тело страницы. Состояние я держу в глобальной булевой, а страницу рисует функция, которая читает её на каждый запрос: пульт всегда показывает то, что плата делает прямо сейчас, а не то, что делала пять минут назад.
Кнопка на странице — обычная ссылка вида <a href='/led/on'><button>ВКЛЮЧИТЬ</button></a>: браузер кликом уходит по пути, плата переворачивает светодиод и отдаёт свежую страницу. Механика та же, что у ссылок в уроке про управление светодиодом из браузера, только ссылки здесь висят на именованных маршрутах, а не на разборе подстрок. JavaScript для переключения не нужен: без него страница честнее и дешевле, а до живых данных без перезагрузки доберётесь через WebSocket-поток в браузер — там другая ступень.
Один нюанс смущает всех на второй минуте: после открытия страницы в журнале два запроса вместо одного — браузер сам просит /favicon.ico, иконку сайта. У платы её нет, и честнее всего отвечать коротким 404 через server.onNotFound(): иконку всё равно никто не ждёт, а плодить для неё целую страницу — гонять лишние байты. Базовый урок отдавал favicon главную страницу — безвредно, но с WebServer.h я всегда вывожу 404: в журнале сразу видно мусорные пути и опечатки в URL.
#include <WiFi.h>#include <WebServer.h> WebServer server(80); // веб-сервер на стандартном 80-м портуconst int LED = 26; // свет на балконе через реле-модульbool svet = false; String stranica() { // пульт собирается из текущего состояния String html = "<!DOCTYPE html><html><head>"; html += "<meta name='viewport' content='width=device-width, initial-scale=1'>"; html += "</head><body><h1>Balkon</h1><p>svet: "; html += svet ? "VKL" : "VYKL"; html += "</p><a href='/led/on'><button>VKLYUCHIT</button></a> "; html += "<a href='/led/off'><button>VYKLYUCHIT</button></a></body></html>"; return html;} void vkl() { // обработчик /led/on svet = true; digitalWrite(LED, HIGH); server.send(200, "text/html", stranica());} void vykl() { // обработчик /led/off svet = false; digitalWrite(LED, LOW); server.send(200, "text/html", stranica());} void setup() { Serial.begin(115200); pinMode(LED, OUTPUT); WiFi.begin("DachaNet", "superParol123"); while (WiFi.status() != WL_CONNECTED) { // сперва сеть... delay(400); Serial.print("."); } Serial.println(); Serial.print("pult: http://"); Serial.println(WiFi.localIP()); server.on("/", []() { // корень — страница-пульт server.send(200, "text/html", stranica()); }); server.on("/led/on", vkl); server.on("/led/off", vykl); server.onNotFound([]() { // favicon и опечатки — короткий 404 server.send(404, "text/plain", "net takogo putya"); }); server.begin();} void loop() { server.handleClient(); // КАЖДЫЙ проход loop, без единого условия // дальше — своя логика без delay(): millis-планировщик и короткие шаги}Порядок в setup() жёсткий: WiFi.begin(), статусный цикл, регистрация маршрутов, server.begin(). Обработчики при этом не бегут сами по себе — их по очереди вызывает server.handleClient() из loop(), когда в сокет кто-то постучался. Это функция-диспетчер: прошла по таблице маршрутов, нашла совпадение, вызвала обработчик, отправила ответ, вернулась. Весь проход занимает единицы миллисекунд, поэтому звать handleClient() можно хоть тысячу раз в секунду — без клиентов он стоит почти ничего. Регистрация маршрутов в setup — это просто заполнение таблицы: сами функции не выполняются, пока диспетчер их не позовёт.
Отсюда правило, которое я считаю главным в этом уроке: в loop() нет ничего, что могло бы задержать handleClient(). Никаких delay(), никаких длинных циклов — периодические дела планирую по millis(), как в уроке про millis вместо delay. Балконный скетч держит в loop() три строчки — handleClient, проверка таймера опроса температуры, чтение кнопки, — и каждая укладывается в пару миллисекунд, потому страница отвечает мгновенно при любом раскладе. Опрос датчика по таймеру раз в десять секунд живёт в той же таблице планировщика, что и мигание светодиода статуса, — два if по millis(), и веб-морда больше ничему не мешает.
Имена путей — половина удобства. /led/on и /led/off читаются сами по себе: в журнале, в закладках и в истории браузера видно, что за команда прошла, — набор вроде /1 и /2 экономит шесть символов и стоит вечер расшифровки через полгода. Когда плата обслуживает и человека, и скрипт из Node-RED, я вывожу машинные ручки под префикс /api/: человек кликает /led/on, скрипт дёргает /api/led/on — логи не путаются. Экономия на одной букве в имени пути не окупает ни одной минуты отладки.
Бюджет страницы и границы однопоточности
Сколько клиентов выдержит такой сервер: он однопоточный, второй браузер ждёт, пока ответит первый. Для пульта с ответами за 5-50 мс это невидимо, но страницу я держу в пару килобайт — текст и кнопки, никаких картинок и тяжёлых стилей: десятикилобайтная графика на трёх клиентах превращает плату в очередь. Мой балконный пульт весит около девятисот байт и открывается быстрее, чем мигает индикатор WiFi. Отсюда и правило про тело ответа: одна страница или одна строка статуса — не обе вещи разом.
Перезагрузка страницы после клика — обычная цена за пульт без JavaScript. Но помните: пульт на GET-ручках повторяет последнюю команду при обновлении страницы кнопкой F5. Для светодиода безвредно — включённый свет не сломается, — а для реле замка или калитки это уже сюжет: там я либо перехожу на POST с формой, либо вешаю одноразовый токен в путь. Правило у меня простое: GET-ручки — только для идемпотентных действий, повтор которых безвреден.
Про границы: сервер без пароля прекрасен внутри квартиры и не должен выходить наружу. Проброс 80-го порта на роутере открывает пульт всему интернету — боты сканируют диапазоны постоянно, и голый сервер они находят за считанные минуты. Если управление нужно с работы, я либо поднимаю VPN до дома, либо добавляю в путь ключ вида /led/on?k=sekret и проверяю его в обработчике — костыльно, но честнее открытой двери. А чтобы адрес платы вообще перестал быть набором цифр — добро пожаловать в следующий урок про имя kotel.local.
| Путь | Что делает плата | Что уходит в браузер |
|---|---|---|
| / | ничего не переключает — рисует пульт | 200, text/html: страница с кнопками и статусом |
| /led/on | svet=true, HIGH на пин реле | 200, свежая страница: свет уже включён |
| /led/off | svet=false, LOW на пин реле | 200, свежая страница: свет уже выключен |
| /favicon.ico | ничего: мусорный запрос самого браузера | 404 через onNotFound, короткой строкой |
| любой другой путь | ничего: опечатка или чужой сканер | тот же 404 — журнал остаётся чистым |
Симулятор: эндпоинты на Serial
Маршрутов WiFi в движке симулятора нет, и притворяться я не буду: ту же диспетчеризацию крутим на поддерживаемом API. Путь из браузера заменяет строка из Serial-монитора, а функция otvet() работает ровно как handleClient — сверила путь с таблицей, выполнила дело, ответила. На живой плате эту же строку шлёт браузер, когда вы кликаете кнопку страницы из скетча выше, — логика совпадает до символа, меняется только транспорт.
Сценарий проверки короткий. Отправьте в монитор строку / — увидите пульт и текущее состояние света; пошлите /led/on — светодиод зажжётся, а в журнале появится метка времени ответа; попробуйте кривой путь и /favicon.ico — получите 404. Секундомер в модели считает те же 5-50 мс, что и живая плата, только «сеть» здесь помещается в один вызов функции. Если сомневаетесь в каком-то маршруте, прогоните все пути подряд — на живой плате я делаю то же самое скриптом из пяти curl-команд перед тем, как считать пульт готовым.
- 1. Кто вызывает обработчики, зарегистрированные через server.on()?
- 2. Второй аргумент server.send(200, "text/html", html) — это:
- 3. Ответ на простой эндпоинт в локальной сети занимает:
- 4. Страница открывается через раз, плата живая. Первым делом проверяю:
- 5. Браузер после открытия страницы сам запросил /favicon.ico. Правильно:
- WebServer.h входит в пакет плат ESP32: server.on(путь, обработчик) держит маршрут и действие в одной строке, server.send(код, MIME, тело) отвечает.
- handleClient() — безусловная первая строка loop(): обработчики вызывает он, а не прерывания; задержали цикл — сервер замолчал.
- Ответ на простой эндпоинт в локалке — 5-50 мс; секунды в ответе означают delay или тяжёлую работу в loop().
- Браузер сам просит /favicon.ico — отвечайте 404 через onNotFound и держите журнал запросов чистым.
- Страницу рисует функция из текущего состояния: каждый ответ показывает то, что плата делает сейчас.
Частые вопросы по теме урока
- Кто вызывает обработчики, зарегистрированные через server.on()?
- handleClient() — диспетчер: он каждый проход loop() сверяет пришедший путь с таблицей маршрутов и вызывает совпавший обработчик. Без него маршруты мертвы. server.handleClient() из каждого прохода loop()
- Второй аргумент server.send(200, "text/html", html) — это:
- Порядок аргументов: код статуса, MIME-тип, тело. MIME говорит браузеру, как читать содержимое — text/html рисует страницу, text/plain показывает текст. MIME-тип содержимого
- Ответ на простой эндпоинт в локальной сети занимает:
- Локальный HTTP без выхода за роутер отвечает за единицы-десятки миллисекунд; до 40-50 мс тянется, когда стек занят держанием WiFi. Секунды значат delay в loop(). 5-50 мс
- Страница открывается через раз, плата живая. Первым делом проверяю:
- Пульсирующий сервер — почти всегда handleClient, заехавший в редкую ветку планировщика, или delay() в loop. Диспетчер должен зваться безусловно и первым. есть ли handleClient() в каждом проходе loop(), без условий
- Браузер после открытия страницы сам запросил /favicon.ico. Правильно:
- Favicon — штатный мусорный запрос браузера. Короткий 404 держит журнал чистым: мусорные пути и опечатки в URL видны сразу, а не тонут в страницах-двойниках. ответить 404 через onNotFound — иконку никто не ждёт