Выключатель света в моей кладовке сидел за стеллажом с банками — чтобы включить, надо было протиснуться боком. Плату с реле я повесил туда ещё зимой, и она исправно сидела в WiFi, но управлял я ею кнопкой на корпусе — то есть никак: руки до выключателя не дотягивались, а до смартфона всегда. Не хватало ровно одного звена — кнопки в браузере.
Она появилась за вечер, и устроена проще, чем кажется. Кнопка на странице — обычная ссылка: <a href="/on"><button>ВКЛЮЧИТЬ</button></a>. Клик по ней — это GET-запрос пути /on, тот самый, который плата умеет ловить. Никакого JavaScript в минимальном варианте: чистый HTTP, как в девяностых, и именно поэтому схема живёт годами без обновлений и библиотек. Как выглядит запрос-строка со стороны платы и почему браузер шлёт лишние запросы — мы уже разбирали в базовом уроке про сервер; здесь поднимаем уровень: состояние, редирект, защита от призраков.
Здесь доводим сервер из прошлого урока до полноценного выключателя: маршруты /on и /off дергают GPIO, страница собирается заново на каждый запрос и показывает актуальное состояние, а редирект 303 не даёт кнопке сработать от простого обновления страницы. Финал — favicon, который кликает без вас, и как его нейтрализовать. Заодно решим вопрос, который портит людям первый вечер: почему запросов в логе всегда больше, чем нажатий.
Клик — это GET-запрос: страница с кнопками
Анатомия кнопки: тег <a> оборачивает <button>, и клик ведёт браузер по адресу из href. Плата обязана на этот запрос ответить хоть чем-то — молчание браузер оформит как «не удаётся получить доступ к сайту», хотя соединение было. Поэтому в каждом обработчике действия я шлю ответ всегда: сначала заголовок редиректа, потом код. Даже favicon получает короткий ответ — молчание для браузера выглядит как обрыв связи, и он может переспрашивать.
Состояние выключателя — переменная bool ledOn в RAM. Страница не хранится готовой: она собирается в handleRoot заново на каждый запрос, и блок if подставляет ту кнопку, которая соответствует текущему состоянию. После перезагрузки платы ledOn сбросится в false и свет окажется выключенным — память пустая; если устройству нужно помнить состояние сквозь reboot, его место в EEPROM или в файле на SD, но для лампы в кладовке мне хватало и RAM. Сборка страницы из пяти строк String стоит на плате меньше миллисекунды, так что пересобирать её на каждый запрос — не расточительство, а честный способ держать страницу синхронной с реальностью.
Ссылка против формы — выбор, который я делаю каждый раз заново. Ссылка шлёт GET: весь запрос виден в адресной строке, его легко печатать руками и логировать. Форма шлёт POST с телом, которое надо дочитывать из клиента. Для вкл/выкл хватает ссылки без вариантов; яркость в процентах или температуру порога — уже форма или путь с параметром вроде /set?value=40, и вот там пригодятся server.arg() из библиотеки. Второй аргумент за ссылки — история браузера: каждый клик остаётся в ней записью, и за неделю видно, кто сколько раз включал свет.
#include <WiFi.h>#include <WebServer.h> const char* ssid = "DachaNet";const char* password = "superParol123"; WebServer server(80);const int LED_PIN = 2; // GPIO 2 — встроенный светодиод DevKitbool ledOn = false; void handleOn() { ledOn = true; digitalWrite(LED_PIN, HIGH); server.sendHeader("Location", "/"); server.send(303); // действие принято, браузер идёт на "/"} void handleOff() { ledOn = false; digitalWrite(LED_PIN, LOW); server.sendHeader("Location", "/"); server.send(303);} void handleRoot() { // страница собирается на каждый запрос String page = "<!DOCTYPE html><html><head>"; page += "<meta name='viewport' content='width=device-width, initial-scale=1'>"; page += "</head><body><h1>Свет в кладовке</h1>"; page += "<p>Сейчас: "; page += ledOn ? "ВКЛ" : "ВЫКЛ"; page += "</p>"; if (ledOn) { page += "<a href='/off'><button style='padding:16px 40px'>ВЫКЛЮЧИТЬ</button></a>"; } else { page += "<a href='/on'><button style='padding:16px 40px'>ВКЛЮЧИТЬ</button></a>"; } page += "</body></html>"; server.send(200, "text/html", page);} void setup() { Serial.begin(115200); pinMode(LED_PIN, 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("/", handleRoot); server.on("/on", handleOn); server.on("/off", handleOff); server.begin();} void loop() { server.handleClient(); // без delay — очередь запросов не ждём}handleOn — три строки и вся магия. digitalWrite(LED_PIN, HIGH) дергает GPIO 2, встроенный светодиод DevKit, с которым мы мигали в первом скетче раздела; для реле меняется только пин и модуль — код обработки тот же. Выбор пина под реле — отдельная наука: часть GPIO ESP32 дергают строку при старте, и реле на них клацает при подаче питания — карта опасных пинов разобрана в уроке про распиновку DevKit; для реле я беру тихие при загрузке пины — у меня это GPIO 16, он молчит и при прошивке. Дальше sendHeader("Location", "/") и send(303): сервер не отдаёт страницу, а говорит браузеру «иди на /». Браузер послушно открывает корень и получает свежую страницу с уже переключённой кнопкой.
Без редиректа адресная строка осталась бы на /on — и первое же обновление страницы (F5, pull-to-refresh на телефоне) отправило бы GET /on снова. С 303 обновление безобидно: браузер идёт по Location, а не повторяет действие. Это та мелочь, из-за которой свет в моих ранних проектах мигал при каждом взгляде на страницу. Проверка на живой плате занимает минуту: откройте /on, посмотрите на адресную строку, обновите — с редиректом свет не дёрнется, без него мигнёт.
Состояние одно на всех: кто последний нажал, тот и прав. Жена с телефона выключает то, что я включил с ноутбука, — и это не баг, а честная модель одного выключателя. Если устройств-пользователей много и права нужны разные, приходят пароли на маршруты или переход на MQTT, но для домашней кладовки я договориться дешевле, чем писать авторизацию. Само состояние при этом стоит печатать и наружу в JSON: маршрут /api/status отдаёт led=1 через serializeJson — те самые навыки из урока про JSON по HTTP, и другие устройства читают его без браузера. А если состояние должно переживать перезагрузку, EEPROM из урока про энергонезависимую память примет его на хранение.
| Путь | Что делает плата | Что видит браузер |
|---|---|---|
| / | Отдаёт страницу с текущим состоянием ledOn | Кнопка ВКЛЮЧИТЬ или ВЫКЛЮЧИТЬ |
| /on | digitalWrite HIGH, затем 303 на "/" | Та же страница, светодиод горит |
| /off | digitalWrite LOW, затем 303 на "/" | Та же страница, светодиод погас |
| /favicon.ico | Иконку просит сам браузер, не человек | Лишний запрос в логе — не клик |
Кнопки в симуляторе: браузер из поля ввода
Браузера в симуляторе нет, поэтому клик заменён полем ввода: печатаете /on — это клик по ссылке ВКЛЮЧИТЬ, печатаете /off — по ВЫКЛЮЧИТЬ. На живой плате те же GET-пути присылает браузер по нажатию кнопок, логика обработки идентична до символа: разбор пути, дерганье пина, ответ с редиректом. Поле ввода честно повторяет и вторую сторону живой страницы: адресная строка принимает что угодно, а не только кнопки, — и сервер обязан вежливо ответить на любой путь.
Следите за строкой led= в логе — это и есть состояние выключателя, оно же горит на панели пинов. Попробуйте /favicon.ico: плата отличит иконку от клика и не станет трогать светодиод — ровно та защита из note выше, только вживую. И заметьте: счётчик clicks растёт только на /on и /off — favicon и выдуманные пути не считаются; так и на живой плате считайте действия, а не запросы, иначе иконка обгонит вас вдвое.
- Доведите счётчик до ума: печатайте clicks= рядом с led= после каждого запроса. За неделю станет видно, кто чаще щёлкает выключателем — вы или домашние.
- Добавьте маршрут /blink: светодиод мигает три раза циклом for с delay(200) внутри обработчика, и только потом уходит 303. На живой плате страница откроется уже после мигания — задержку видно глазами.
- Соберите JSON-состояние по мотивам урока про JSON по HTTP: путь /api/status печатает led= и up= — другие устройства читают его без браузера.
- Сделайте вежливый 404: все пути, кроме четырёх табличных, отвечают строкой net takoj knopki и попадают в лог. Сканеры и предзагрузки перестанут путаться с людьми.
- Кнопка на странице — ссылка: клик по <a href="/on"> шлёт GET /on, плата дергает пин и отвечает
- Состояние — bool ledOn в RAM; страница собирается на каждый запрос и всегда показывает актуальную кнопку
- Редирект 303 + Location: / возвращает браузер на свежую страницу, и F5 не повторяет действие
- GPIO 2 — встроенный светодиод DevKit; для реле код тот же, меняется пин и модуль
- favicon и предзагрузки кликают без человека: действия только на явных /on и /off, остальное — 404 без дерганья пинов
- 1. Что шлёт браузер при клике по ссылке <a href="/off"><button>?
- 2. Почему после /on сервер отвечает 303 с Location: /?
- 3. Свет был включён, плату перезагрузили. Что со светодиодом?
- 4. В логе сервера появился запрос /favicon.ico. Кто его прислал?
- 5. Как сделать, чтобы кнопка на странице менялась ВКЛ↔ВЫКЛ?
Выключатель собран целиком: браузер жмёт кнопки, плата дергает пины, страница показывает правду. Дальше — по росту задач: когда устройств станет несколько и каждому нужны свои данные, HTTP-опрос перестанет хватать, и на сцену выйдет MQTT — плата перестанет быть единственным сервером в доме, об этом у нас отдельный урок про MQTT на ESP32. А пока не спешите с классикой: реле на пин, страница из этого урока, DHCP-резервация адреса из урока про подключение к роутеру — и лампа в кладовке станет умной без единого облака.
Мой выключатель живёт так второй год: один вечер на скетч, одна резервация на роутере, ноль обслуживаний. Самая дорогая доработка оказалась механической — переставить банку с гвоздями, чтобы корпус реле влез на полку. С самоделками так всегда: электроника за вечер, коробка — за выходной, и счётчик кликов в логе расскажет о семье больше, чем любой ежедневник.
Частые вопросы по теме урока
- Что шлёт браузер при клике по ссылке <a href="/off"><button>?
- Клик по ссылке — это переход по адресу: браузер шлёт GET /off. Никакого JavaScript в минимальном варианте не нужно. GET-запрос пути /off
- Почему после /on сервер отвечает 303 с Location: /?
- Без редиректа адресная строка остаётся на /on, и первое же F5 повторяет действие. С 303 браузер идёт на / и показывает свежее состояние. Браузер возвращается на страницу, и обновление не жмёт /on повторно
- Свет был включён, плату перезагрузили. Что со светодиодом?
- Состояние выключателя живёт в оперативной памяти и после перезагрузки пусто. Сквозь reboot его хранят в EEPROM, но для лампы меня это не касалось. Выключится: bool ledOn жил в RAM и сбросился
- В логе сервера появился запрос /favicon.ico. Кто его прислал?
- Браузер шлёт запрос иконки после каждой страницы, поэтому запросов в логе всегда больше, чем кликов. Действия по пинам на favicon отвечать не должны. Браузер автоматически просит иконку сайта — это не клик
- Как сделать, чтобы кнопка на странице менялась ВКЛ↔ВЫКЛ?
- Страница собирается на каждый запрос, и if по состоянию подставляет нужную кнопку. Обновление — клик, состояние — переменная, всё честно. Собирать страницу в handleRoot заново, подставляя кнопку по ledOn