Понедельник, десять вечера. Я лежу на диване и соображаю: вставать и идти выключать свет в комнате лень. Светом управляло реле на ESP32, но включалось оно физической кнопкой — как у пещерных людей. Реле-модуль коммутировало лампу через опторазвязку, схема занимала три провода, а вся магия была в софте. Через вечер плата уже слушала 80 порт, а я гасил лампу кнопкой в браузере телефона, не поднимая головы от подушки. Тот момент, когда веб-страница оживает на плате размером со спичечный коробок, — один из самых довольных за весь мой ардуино-стаж.
В этом уроке собираем такой сервер с нуля: как браузер находит плату в локальной сети, как выглядит запрос-строка, которую шлёт браузер, как плата отвечает HTML и по какому слову в запросе решает, включать светодиод или выключать. В симуляторе весь обмен сервера с клиентом будет виден в Serial-логе — вы своими глазами увидите то, что на реальной плате печатается в монитор порта. А в финале ждёт чек-лист на случай, когда страница на живой плате откажется открываться: каждый пункт я собрал на собственных ошибках.
Как браузер попадает на плату
Адрес http://192.168.1.100/ читается по частям: http — протокол, 192.168.1.100 — адрес платы в локальной сети, порт — 80, путь — одинокий слэш. Порт 80 — стандарт для HTTP, поэтому в адресе его никто не пишет; если однажды поднимете второй сервер на 8080, адрес станет http://192.168.1.100:8080/. И условие по сети: адреса вида 192.168.x.x видны только внутри домашней сети — из интернета, с работы или с мобильного трафика страница не откроется, как ни обновляй.
Почему плата доступна по числам, а любимые сайты — по именам? Для имён есть DNS — служба, которая переводит «ya.ru» в IP-адрес. Мини-сервер на плате DNS не поднимает, да и не нужно: в домашней сети проще обращаться к плате по адресу напрямую. Если захочется имени вида esp32.local, плате поможет библиотека ESPmDNS — она объявляет имя в локальной сети, но чтобы имя открывалось, нужен телефон с поддержкой Bonjour, а числа работают всегда. В наших уроках — только числа.
Браузер отправляет плате несколько строк текста, и для нас важна только первая — запрос-строка. Если на странице кнопка ведёт на /on, строка будет такой: GET /on HTTP/1.1 — метод, путь и версия протокола. Вот как выглядит весь запрос целиком, плата получает его обычным текстом:
GET /on HTTP/1.1Host: 192.168.1.100User-Agent: Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36Accept: text/html,application/xhtml+xmlConnection: keep-alive // после заголовков идёт пустая строка — конец запросаСкетч сервера: слушаем порт 80
#include <WiFi.h> const char* ssid = "MoyaSetka";const char* password = "superParol"; WiFiServer server(80); // слушаем порт 80 — стандартный для HTTPconst int ledPin = 2; // GPIO 2 — встроенный светодиод платыbool ledOn = false; 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.begin();} void loop() { WiFiClient client = server.available(); // появился клиент? if (!client) return; // нет — выходим и ждём дальше String request = client.readStringUntil('\n'); // первая строка запроса Serial.print("Запрос: "); Serial.println(request); if (request.indexOf("GET /on") >= 0) { ledOn = true; } if (request.indexOf("GET /off") >= 0) { ledOn = false; } digitalWrite(ledPin, ledOn); client.println("HTTP/1.1 200 OK"); client.println("Content-Type: text/html"); client.println("Connection: close"); client.println(); // пустая строка — конец заголовков client.println("<!DOCTYPE html><html><head>"); client.println("<meta name='viewport' content='width=device-width, initial-scale=1'>"); client.println("</head><body><h1>Свет в комнате</h1>"); if (ledOn) { client.println("<a href='/off'><button>ВЫКЛЮЧИТЬ</button></a>"); } else { client.println("<a href='/on'><button>ВКЛЮЧИТЬ</button></a>"); } client.println("</body></html>"); client.stop(); // соединение закрыто Serial.println("Ответ отправлен");}Ключевые строки. WiFiServer server(80) создаёт сервер, server.begin() запускает его в setup после подключения к WiFi — именно в таком порядке, до сети серверу некуда стучаться. В loop() server.available() возвращает клиента, если кто-то подключился, — и пустой объект, если нет; отсюда if (!client) return;, чтобы loop() не зависал в ожидании.
client.readStringUntil('\n') читает первую строку запроса до перевода строки — она-то и содержит команду. Поиск indexOf("GET /on") надёжнее точного сравнения строк: даже если строка пришла с хвостатым \r от Windows-клиента, поиск по подстроке не сломается. Дальше — digitalWrite по состоянию и сборка HTML ответа построчно.
Ответ плата собирает так же текстово: строка статуса HTTP/1.1 200 OK, заголовок Content-Type: text/html, пустая строка и HTML страницы. Кнопка на ней — обычная ссылка: нажатие заставляет браузер снова постучаться к плате, уже с путём /off. Никакой магии и никакого JavaScript в минимальном варианте — чистый HTTP, как в девяностых, и это прекрасно. Почему ссылка, а не форма? Форма шлёт POST с телом, который надо дочитывать из клиента, — лишние строки там, где хватает одного тега; до форм доберётесь, когда понадобится передавать значения вроде яркости светильника.
Ловушка, о которой молчат примеры: браузер сам запрашивает `/favicon.ico` — иконку сайта — сразу после открытия страницы. Вы увидите два запроса вместо одного, и это не баг. В нашем скетче favicon получает ту же страницу, что безвредно; в больших проектах на favicon отвечают коротким 404 и не грузят плату. Второе число с условием: Connection: close плюс client.stop() закрывают соединение сразу после ответа — без этого браузер держит соединение открытым, и пары клиентов на плате может не хватить.
Отдельно про симулятор ниже: отдельного браузера в нём нет, поэтому приход запроса имитируется — раз в шесть секунд виртуальный клиент присылает запрос-строку, а сервер обрабатывает её той же логикой, что и реальная плата, печатая в Serial-лог запрос, решение и то, что ушло в ответ. Именно так выглядит монитор порта настоящей платы, когда вы жмёте кнопки на странице с телефона. И мнение моё тут такое, с условием: сервер без пароля прекрасен внутри квартиры, а вот пробрасывать 80 порт на роутере в глобальную сеть я не стану, пока не добавлю хотя бы проверку ключа в запросе — голые серверы боты находят за считанные минуты.
Сервер в симуляторе: весь обмен — в Serial-логе
Эксперименты: закрепите руками
- Добавьте путь
/blink: если запрос содержитGET /blink, светодиод мигает три раза (цикл for с delay(200)), и только потом сервер шлёт ответ. Проверьте на реальной плате, что страница открывается уже после мигания. - Научите сервер вежливости: всем запросам, где нет ни
/on, ни/off, отвечайте строкойHTTP/1.1 404 Not Foundбез HTML. Логику удобно разложить через if/else по результатам двух indexOf, а нераспознанные пути печатать в Serial — за неделю вы увидите, кого ищут чужие сканеры. - Соберите страницу статуса: путь
/statusвозвращает HTML, где текст ВКЛ или ВЫКЛ подставляется при сборке ответа черезif (ledOn). У вас получится страничка-табло текущего состояния реле, и обновляется она простым нажатием. - Выведите в Serial количество обработанных клиентов: счётчик увеличивается на каждого
client. Запомните число после первого открытия страницы — оно будет больше единицы, и вы знаете виновника: favicon.
- Веб-сервер на ESP32: WiFiServer server(80), server.begin() после подключения к WiFi, в loop() — server.available()
- Первая строка запроса браузера — GET /путь HTTP/1.1; из неё indexOf-ом достаём команду
- Ответ: HTTP/1.1 200 OK, Content-Type: text/html, пустая строка, HTML, затем client.stop()
- Браузер сам запрашивает /favicon.ico — не пугайтесь второго запроса
- Плата и телефон должны быть в одной WiFi-сети; адрес всегда http://, порт 80 в адресе не пишут
- 1. Какой порт слушает наш сервер и почему его нет в адресе http://192.168.1.100/?
- 2. Что содержит первая строка запроса браузера?
- 3. Плата живёт по адресу 192.168.1.100, а телефон включил мобильный интернет. Что увидит браузер?
- 4. Зачем в конце обработки клиента вызывать client.stop()?
- 5. Открыли страницу, а светодиод переключился дважды за клик. Кто виноват?
- 6. Что вернёт server.available(), когда браузеров рядом нет?
Раздел по сети у вас в кармане: плата подключается к WiFi, ходит за данными и сама отвечает браузеру. Логичный следующий шаг — повесить на GPIO датчик DHT22 и собрать табло температуры, которое видно с телефона: сервер из этого урока плюс один analogRead по пути /temp. Схема остаётся той же: датчик опрашивается в loop() по расписанию, значение кладётся в String ответа, страница обновляется кнопкой в браузере. А когда захочется, чтобы данными пользовались несколько устройств и история копилась на сервере — приходите в раздел про MQTT: там плата перестаёт быть единственным сервером в доме. И держите в голове правило из начала урока: локальный сервер — для дома, в глобальную сеть — только с паролем.
Частые вопросы по теме урока
- Какой порт слушает наш сервер и почему его нет в адресе http://192.168.1.100/?
- Браузер по умолчанию стучится на порт 80 для http://. Нестандартный порт пишут в адресе явно, например :8080, — его появление в адресе всегда заметно. 80 — это порт по умолчанию для HTTP
- Что содержит первая строка запроса браузера?
- Запрос-строка — метод GET, путь и версия протокола. Именно из неё сервер достаёт команду (/on, /off). GET /путь HTTP/1.1 — метод, путь и версия протокола
- Плата живёт по адресу 192.168.1.100, а телефон включил мобильный интернет. Что увидит браузер?
- Адреса 192.168.x.x существуют только внутри локальной сети. Из LTE или интернета до платы не достучаться без особых настроек роутера. Ошибку соединения: локальный адрес недоступен извне
- Зачем в конце обработки клиента вызывать client.stop()?
- Без stop() браузер держит соединение открытым, ресурсы сервера кончаются, и новые клиенты перестают подключаться. Закрыть соединение и освободить сервер для следующего браузера
- Открыли страницу, а светодиод переключился дважды за клик. Кто виноват?
- Браузер сам запрашивает иконку сайта после загрузки страницы. Сервер получает два запроса — поэтому в логе их всегда больше, чем кликов. Запрос /favicon.ico, который браузер шлёт автоматически
- Что вернёт server.available(), когда браузеров рядом нет?
- available() отдаёт клиента, если он есть. Проверка if (!client) return; не даёт loop() зависнуть на пустом клиенте. Объект без активного клиента — обработку пропускаем через if (!client) return;