Сервер из урока про WiFiServer у меня жил и работал: две кнопки на странице, светодиод слушается. А потом я захотел больше — яркость, режим, график показаний — и разбор запроса превратился в кашу. Все команды жили в одной indexOf-простыне: если путь /led — одно, если /mode — другое, если /bright — третье, и к каждому ещё параметр вытащи. Когда я вписал четвёртое действие, задел переменную соседнего if-а — и светодиод молча перестал слушаться, хотя страница открывалась как ни в чём не бывало. Полчаса я смотрел в код, не понимая, где сломался.
Сломалась архитектура, а не строка. Все запросы — в одной ветке кода, и каждое новое действие тащит за собой правку общего условия. Библиотека WebServer.h, которая ставится вместе с ядром ESP32, переворачивает подход: запросы раздаются по таблице маршрутов, у каждого пути — собственная функция-обработчик, а аргументы после вопросительного знака библиотека парсит сама. В этом уроке пересобираем тот сервер на новых рельсах: маршруты /led и /status, параметры вроде state=on, вежливый 404 и JSON-ответ для скриптов.
Таблица маршрутов вместо простыни if
Идея та же, что в serial-командном парсере: каждая команда получает свою функцию, и общий цикл только разводит потоки. Только здесь команды приходят не из монитора, а по HTTP: браузер стучится на адрес платы, и первая строка запроса — GET /led?state=on HTTP/1.1 — это маршрут /led с аргументом state=on. Регистрация маршрута — одна строка: server.on("/led", handleLed). Столько раз, сколько маршрутов. Ни одного if на уровне «какой путь пришёл» — библиотека сравнит пути сама и вызовет нужную функцию.
Пара слов об именах и методах. WebServer.h понимает и фильтр метода: server.on("/led", HTTP_GET, handleLed) отвечает только на GET, а форма с POST уйдёт мимо — для форм регистрируйте отдельный маршрут с HTTP_POST. Обработчики я называю по шаблону «handle + путь»: handleLed, handleStatus, handleReset — через месяц в таблице из восьми маршрутов без подсказок видно, кто за что отвечает. Типичный набор моего домашнего проекта: / — страница, /led?state= — команда, /status — JSON, /reset — перезагрузка. Маршруты растут списком по одной строке, и код не превращается в ветвистый куст, который страшно трогать.
Три вызова держат весь сервер. server.on(путь, функция) — таблица маршрутов, записывается один раз в setup(). server.handleClient() в loop() — насос, который забирает из сети пришедший запрос, находит маршрут и запускает обработчик; вызывается на каждом обороте цикла. server.onNotFound(функция) — сачок для всего остального: путь, которого нет в таблице, попадает туда, и вы сами решаете, что ответить. Внутри обработчика вам доступны аргументы запроса, URI и метод — с ними и работаем дальше.
Аргументы — самая вкусная часть. Браузер шлёт /led?state=on&brightness=120, а WebServer.h уже разложил это на полки: server.arg("state") вернёт "on", server.arg("brightness") — "120", server.args() — количество аргументов, server.argName(0) — имя первого. Процент-кодирование библиотека тоже разворачивает: %20 из пробела превратится обратно в пробел до того, как вы увидите строку. Единственная ловушка — у server.arg("имя") нет встроенной проверки существования: нет такого аргумента — вернётся пустая строка. Поэтому рядом живёт server.hasArg("имя"), и моё правило — сначала hasArg, потом arg. Добытое число — уже топливо для периферии: brightness=120 на живой плате становится заполнением ШИМ. Ниже — интерактивная песочница: покрутите ползунок duty и посмотрите, как число 0-255 превращается в яркость светодиода и в прямоугольный сигнал на осциллограмме.
Скважность, частота и яркость светодиода — крутите ползунки и смотрите на осциллограмму.
Подгружаем интерактив…
#include <WiFi.h>#include <WebServer.h> // ставится вместе с ядром ESP32 const char* ssid = "DachaNet";const char* password = "superParol123"; WebServer server(80); // маршрутизатор запросовconst int ledPin = 2; // встроенный светодиод DevKitbool ledOn = false; void handleLed() { // GET /led?state=on if (server.hasArg("state")) { ledOn = (server.arg("state") == "on"); digitalWrite(ledPin, ledOn); } String page = ledOn ? "LED ВКЛ. <a href='/led?state=off'>выключить</a>" : "LED ВЫКЛ. <a href='/led?state=on'>включить</a>"; server.send(200, "text/html", page);} void handleStatus() { // GET /status — данные для скриптов String json = "{\"led\":\""; json += ledOn ? "on" : "off"; json += "\",\"uptime\":"; json += millis() / 1000; json += "}"; server.send(200, "application/json", json);} void handleNotFound() { // всё, чего нет в таблице server.send(404, "text/plain", "Нет маршрута: " + server.uri());} void setup() { Serial.begin(115200); pinMode(ledPin, OUTPUT); WiFi.begin(ssid, password); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(); Serial.print("Открывайте в браузере: http://"); Serial.println(WiFi.localIP()); server.on("/led", handleLed); // таблица маршрутов server.on("/status", handleStatus); server.onNotFound(handleNotFound); server.begin();} void loop() { server.handleClient(); // насос раздачи запросов}Разбор по строкам. Обработчик handleLed сначала проверяет server.hasArg("state") — если кто-то открыл голый /led без аргумента, состояние не трогается, а страница всё равно отдаётся. Значение достаётся server.arg("state") и сравнивается с "on"; светодиод переключается одним digitalWrite. Ответ — server.send(код, тип, тело): для людей text/html, для скриптов application/json. Разделение «людям HTML, программам JSON» я считаю главным правилом аккуратного сервера: страница /led для пальца, /status — для скрипта, который рисует табло.
JSON здесь собран вручную конкатенацией, потому что зависимостей меньше. Как только захотите вложенные объекты и массивы — переходите на ArduinoJson, у нас есть урок про сборку своего JSON: там serializeJson складывает документ в строку без ручных кавычек-лесенок, в которых я дважды терял экранирование. Обработчик 404 печатает server.uri() — путь, который запросили. Через неделю лог 404-х покажет вам и опечатки собственных закладок, и чужие сканеры, которые пробуют плату на известные дыры.
Строку в число превращайте честно: server.arg("brightness").toInt() даёт 120 из "120", и дальше можно рулить яркостью через ШИМ или позицией сервы. На примере — второй аргумент в строке запроса: /led?state=on&brightness=120 — библиотека разложит оба, и arg("brightness") вернёт именно "120", а не всю строку аргументов. Мой грех первых серверов — выписывать значения в Serial и вбивать их руками: toInt() есть прямо под рукой, и аргумент из URL становится числом за одну строку.
Число с условием, которое стоит помнить: пока выполняется обработчик, весь остальной скетч стоит. handleClient() — обычный вызов в loop(), он не умеет параллелить: один браузер ждёт — датчики, экран и кнопки ждут вместе с ним. Для страницы в 200 байт это миллисекунды, и я живу спокойно. Но если в обработчик появится delay() или цикл на пять секунд — сервер ослепнет на всё это время. Именно на этом я и обожёгся в первый вечер, а систематически лечится это асинхронным сервером — ему посвящён следующий урок про ESPAsyncWebServer.
| Маршрут | Что делает | Ответ |
|---|---|---|
| /led?state=on | Включает светодиод и шлёт страницу со ссылкой выключить | 200 OK, text/html |
| /led?state=off | Гасит светодиод | 200 OK, text/html |
| /status | Отдаёт состояние в JSON — для скриптов и табло | 200 OK, application/json |
| /что-угодно-другое | Нет в таблице — уходит в ловушку onNotFound | 404 Not Found |
Маршруты в симуляторе: печатаем запросы руками
Браузера в симуляторе нет, и роутинг WebServer.h движок не понимает — поэтому встанем на место браузера сами. Маршрут, который на живой плате присылает страница, вы печатаете в строку ввода монитора: /led?state=on, /status или любую окрошку — и «сервер» разбирает её тем же способом, что и библиотека: путь до вопросительного знака, аргументы после. На живой плате эту работу делает WebServer.h из скетча выше; логика разбора и ответы — один в один.
План на прогонку такой. Сначала /led?state=on — светодиод загорится, и сервер ответит строкой 200 OK led=on. Потом /led?state=off — погаснет. Затем /status — увидите JSON с текущим состоянием, ровно тот, который скрипт-табло разбирает на живой плате. И напоследок заведомо кривой путь вроде /nope — сервер честно ответит 404 Not Found и покажет путь, которого не понял. Если хотите потренировать разбор строк подробнее, у нас есть урок про команды из монитора порта — там тот же приём, только без сети.
- 1. Как из запроса GET /led?state=on обработчик достаёт значение?
- 2. Что вернёт server.arg("state"), если в запросе такого аргумента нет?
- 3. Зачем нужен server.onNotFound(handleNotFound)?
- 4. Страница стала открываться с задержкой около секунды после правки скетча. Что проверите первым?
- 5. Какой Content-Type правильный для ответа маршрута /status со скобками и кавычками внутри?
- WebServer.h заменяет простыню if таблицей: server.on("/led", handleLed) в setup() и server.handleClient() в loop()
- Аргументы /led?state=on уже разобраны: server.arg("state"), проверка — server.hasArg(), количество — server.args()
- Ответ одним вызовом server.send(200, "text/html", страница); JSON — с типом application/json
- Неизвестные пути ловит server.onNotFound: отвечайте 404 и печатайте server.uri() — лог покажет и опечатки, и сканеры
- delay() в loop() ослепляет сервер: паузы — на millis, обработчик — короткий
Прежде чем писать маршруты на живой плате, я советую минуту бумаги: столбик путей, рядом аргументы, справа — что отвечает каждый. Для поливалки мой список выглядел так: / — страница с двумя ссылками, /pump?state=on и /pump?state=off — реле, /status — JSON с влажностью и состоянием реле, всё остальное — 404. Четыре строки таблицы маршрутов, и код пишется сам: каждый маршрут — короткая функция, в которой нет ни одного чужого дела. Бумага ещё и ловит лишнее: когда маршрут в списке нечем оправдать, его вычёркиваешь до того, как он съел вечер.
Теперь сервер выглядит как взрослая программа: маршруты — отдельные функции, неизвестное — в 404, данные — отдельным JSON-маршрутом. Предел этой архитектуры — один клиент за раз: пока обработчик отдаёт страницу, остальные ждут, и каждый delay() в любом месте цикла это усугубляет. Что меняется, когда обработчики переезжают в сетевую задачу чипа и loop() остаётся свободным при десяти открытых браузерах — разберём в уроке про асинхронный сервер. А если нужна сеть без роутера — плата сама раздаёт свою точку доступа, как в уроке про SoftAP: портал настройки на 192.168.4.1 собирается из этих же маршрутов.
Частые вопросы по теме урока
- Как из запроса GET /led?state=on обработчик достаёт значение?
- WebServer.h сама парсит строку запроса: server.arg("имя") возвращает значение аргумента, server.args() — их количество, server.argName(0) — имя первого. server.arg("state") — аргументы уже разобраны
- Что вернёт server.arg("state"), если в запросе такого аргумента нет?
- Нет аргумента — вернётся пустая строка. Без hasArg() сравнение с "on" даст false, и устройство молча поведёт себя не так, как ждёт человек. Пустую строку — поэтому перед чтением проверяют server.hasArg()
- Зачем нужен server.onNotFound(handleNotFound)?
- Всё, что не совпало с таблицей server.on, уходит в onNotFound. Там отвечают 404 и печатают server.uri() — за неделю в логе видны и опечатки, и чужие сканеры. Отвечать 404 всем маршрутам, которых нет в таблице server.on
- Страница стала открываться с задержкой около секунды после правки скетча. Что проверите первым?
- handleClient() вызывается в loop() и во время delay() не работает. Каждый клик ждёт окончания паузы, а несколько кликов складываются в очередь. Нет ли delay() в loop() — он останавливает и server.handleClient()
- Какой Content-Type правильный для ответа маршрута /status со скобками и кавычками внутри?
- JSON отдают с типом application/json. Правило разделения: людям — text/html, скриптам — application/json. application/json — для JSON-ответов