Wi-Fi · веб-сервер
Веб-сервер на Arduino: страница управления в браузере
Плата отдаёт браузеру обычную веб-страницу с кнопками — и телефон становится пультом, без единого установленного приложения. Разбираем, что при этом происходит по шагам, где держать страницу, почему она иногда «висит» и откуда её вообще можно открыть. Схемы живые, все три скетча собраны компилятором.
Короткий ответ. Плата с Wi-Fi умеет быть маленьким сайтом: открываете в браузере её адрес — видите свою страницу с кнопками. Приложение не нужно, работает с любого телефона и компьютера. Но только пока вы дома: из интернета такая страница не открывается — домашний роутер прячет плату. Как достать её издалека, разобрано в статье про MQTT.
Что такое «сервер на плате» и почему это не сайт
Слово «сервер» звучит внушительно, но означает простую вещь: программа, которая ждёт, когда к ней обратятся, и отвечает. Ваша плата всё время слушает: не постучится ли кто-нибудь. Когда браузер стучится и просит страницу, плата собирает текст этой страницы и отправляет его. Всё.
Сайтом это не становится по двум причинам. Во-первых, у платы нет имени в интернете — у неё есть только внутренний адрес вида 192.168.1.47, который существует лишь внутри вашей квартиры. Во-вторых, снаружи её закрывает роутер. Поэтому правильнее думать об этом так: вы делаете не сайт, а пульт, который открывается в браузере. И это очень удобно: ничего не надо ставить ни на телефон, ни на компьютер, работает и на Android, и на iPhone, и на ноутбуке.
Перед этой страницей стоит прочитать как плату вообще подключить к сети: здесь мы считаем, что плата уже в сети и напечатала свой адрес в мониторе порта.
Что происходит, когда вы набрали адрес платы
Разберём по шагам — почти каждая непонятная мелочь дальше объясняется именно этой последовательностью.
Из этой картинки сразу следуют две практические вещи. Первая: плата обязана отвечать на всё, о чём её спрашивают, даже на запрос значка вкладки — иначе браузер будет терпеливо ждать несколько секунд, и страница откроется «через раз». Вторая: пока плата собирает и отправляет страницу, она не делает ничего другого. Значит, страница должна быть маленькой, а программа не должна её задерживать.
Самый маленький сервер: две ссылки и светодиод
Начнём с варианта, который умещается на экране и работает сразу. Страница здесь собирается прямо в памяти, а кнопки — обычные ссылки.
// Самый маленький веб-сервер: страница с двумя ссылками.
// Открываете адрес платы в браузере телефона - и щёлкаете светодиодом.
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
ESP8266WebServer server(80);
#define STATUS_LED LED_BUILTIN
#define LED_ON LOW
#define LED_OFF HIGH
#else
#include <WiFi.h>
#include <WebServer.h>
WebServer server(80);
#define STATUS_LED 2
#define LED_ON HIGH
#define LED_OFF LOW
#endif
const char WIFI_SSID[] = "имя вашей сети";
const char WIFI_PASS[] = "пароль от сети";
bool lampOn = false;
void applyLamp() {
digitalWrite(STATUS_LED, lampOn ? LED_ON : LED_OFF);
}
// Страница собирается прямо в памяти. Кавычки внутри - одинарные:
// так не приходится их экранировать.
void handleRoot() {
String page = "<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>";
page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
page += "<title>Плата</title></head><body style='font-family:sans-serif'>";
page += "<h1>Светодиод на плате</h1>";
page += lampOn ? "<p>Сейчас: горит</p>" : "<p>Сейчас: не горит</p>";
page += "<p><a href='/on'>Включить</a> <a href='/off'>Выключить</a></p>";
page += "</body></html>";
server.send(200, "text/html; charset=utf-8", page);
}
// После нажатия отправляем браузер обратно на главную: иначе он останется
// на пустой странице /on и покажет её адрес.
void handleSwitch(bool on) {
lampOn = on;
applyLamp();
server.sendHeader("Location", "/");
server.send(303);
}
void setup() {
Serial.begin(115200);
pinMode(STATUS_LED, OUTPUT);
applyLamp();
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Наберите в браузере адрес: ");
Serial.println(WiFi.localIP());
server.on("/", handleRoot);
server.on("/on", []() { handleSwitch(true); });
server.on("/off", []() { handleSwitch(false); });
// Браузер всегда просит значок вкладки. Если не ответить - будет ждать.
server.onNotFound([]() { server.send(404, "text/plain", "нет такой страницы"); });
server.begin();
}
void loop() {
// Эту строчку нельзя пропускать: именно она обслуживает браузер.
// Любой длинный delay() в loop() делает страницу «висящей».
server.handleClient();
}
Три строчки в этом скетче стоит разобрать отдельно.
server.on("/", handleRoot)— «когда попросят корень сайта, вызови вот эту функцию». Так же вешаются и остальные адреса.server.send(200, ...)— ответ. Число 200 означает «всё в порядке, вот твоя страница». Типtext/html; charset=utf-8обязателен: без указания кодировки русские буквы превратятся в кракозябры.server.handleClient()вloop()— та самая строчка, ради которой всё работает. Именно она проверяет, не постучался ли кто-то, и вызывает ваши обработчики.
Обратите внимание на ответ с кодом 303 после нажатия: он говорит браузеру «вернись на главную». Без него человек останется на пустой странице /on и будет недоумевать.
Почему страница моргает и как это лечится
У варианта со ссылками есть заметный недостаток: на каждое нажатие браузер уходит на другой адрес и получает всю страницу заново. Экран моргает, показания «прыгают», а если на странице было поле ввода — его содержимое пропадает.
Лечится это тем, что кнопка перестаёт быть ссылкой. Вместо перехода она отправляет короткий запрос и остаётся на месте. А показания страница спрашивает сама, раз в секунду, и подставляет свежие числа в уже нарисованный текст.
Второе улучшение в следующем скетче — страница переехала из оперативной памяти во флеш-память. Это важнее, чем кажется: у ESP8266 свободной оперативной памяти около сорока килобайт, и красивая страница со стилями съедает её заметную часть именно в тот момент, когда плата и так занята.
// Панель управления, которая не перезагружает страницу.
// Кнопки шлют короткие запросы, а показания обновляются раз в секунду.
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
ESP8266WebServer server(80);
#define STATUS_LED LED_BUILTIN
#define LED_ON LOW
#define LED_OFF HIGH
#else
#include <WiFi.h>
#include <WebServer.h>
WebServer server(80);
#define STATUS_LED 2
#define LED_ON HIGH
#define LED_OFF LOW
#endif
const char WIFI_SSID[] = "имя вашей сети";
const char WIFI_PASS[] = "пароль от сети";
bool lampOn = false;
// Страница лежит во флеш-памяти, а не в оперативной: PROGMEM и send_P.
// Внутри только одинарные кавычки - чтобы ничего не экранировать.
const char PAGE_HTML[] PROGMEM = R"rawliteral(
<!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-family:sans-serif;margin:24px;max-width:420px}
button{font-size:18px;padding:12px 20px;margin-right:8px}
.val{font-size:22px;margin:16px 0}
</style>
</head>
<body>
<h1>Панель платы</h1>
<div class='val'>Светодиод: <b id='lamp'>...</b></div>
<button onclick='cmd(1)'>Включить</button>
<button onclick='cmd(0)'>Выключить</button>
<div class='val'>Плата работает: <b id='up'>...</b> с</div>
<script>
function cmd(v){ fetch('/set?led=' + v).then(refresh); }
function refresh(){
fetch('/state').then(function(r){ return r.text(); }).then(function(t){
var p = t.split(';');
document.getElementById('lamp').textContent = p[0] === '1' ? 'горит' : 'не горит';
document.getElementById('up').textContent = p[1];
});
}
setInterval(refresh, 1000);
refresh();
</script>
</body>
</html>
)rawliteral";
void applyLamp() {
digitalWrite(STATUS_LED, lampOn ? LED_ON : LED_OFF);
}
void handleRoot() {
server.send_P(200, "text/html; charset=utf-8", PAGE_HTML);
}
// Состояние отдаём двумя числами через точку с запятой: для двух значений
// городить JSON незачем, а разобрать такую строку браузер умеет сам.
void handleState() {
String answer = lampOn ? "1;" : "0;";
answer += String(millis() / 1000);
server.send(200, "text/plain", answer);
}
void handleSet() {
if (server.hasArg("led")) {
lampOn = (server.arg("led") == "1");
applyLamp();
}
server.send(200, "text/plain", "ok");
}
void setup() {
Serial.begin(115200);
pinMode(STATUS_LED, OUTPUT);
applyLamp();
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Наберите в браузере адрес: ");
Serial.println(WiFi.localIP());
server.on("/", handleRoot);
server.on("/state", handleState);
server.on("/set", handleSet);
server.onNotFound([]() { server.send(404, "text/plain", "нет такой страницы"); });
server.begin();
}
void loop() {
server.handleClient();
}
Состояние здесь отдаётся не в модном формате, а двумя числами через точку с запятой: 1;432 означает «светодиод горит, плата работает 432 секунды». Для двух значений городить что-то сложнее незачем, а разобрать такую строку страница умеет сама.
И маленькая, но важная деталь: внутри страницы использованы только одинарные кавычки. Так текст страницы можно писать в скетче как есть, не расставляя перед каждой кавычкой обратную косую черту. Это тот приём, из-за отсутствия которого чужие примеры выглядят пугающе.
Где держать саму страницу
Три способа, и выбор между ними зависит от того, насколько сложной будет ваша панель.
| Способ | Разумный размер | Плюсы | Минусы |
|---|---|---|---|
| Собирать в переменной String | сотни байт | Проще всего: страницу можно составлять из кусочков и подставлять в неё числа | Живёт в оперативной памяти и режет её на кусочки. Большая страница легко роняет плату |
| Держать во флеш-памяти (PROGMEM) | десятки килобайт | Оперативную память не трогает совсем, отдаётся одной строчкой send_P | Страница вшита в прошивку: чтобы поправить кнопку, нужно перепрошить плату |
| Класть файлами в файловую систему | сотни килобайт | Можно хранить картинки, стили и несколько страниц, менять их отдельно от программы | Файлы нужно заливать в плату отдельным действием, и часть памяти уходит под саму файловую систему |
Практическое правило: пока страница помещается в один экран кода — держите её в памяти, так проще подставлять числа. Как только появились стили и несколько кнопок — переносите во флеш-память. Файловая система нужна тогда, когда на странице появляются картинки или вы хотите менять оформление, не трогая программу.
И общий совет: не тащите на страницу платы внешние библиотеки оформления. Они весят сотни килобайт, грузятся из интернета, а в домашней сети без интернета не загрузятся вовсе — и панель откроется голой.
Откуда эта страница откроется, а откуда нет
Самый частый вопрос после первого успеха: «дома работает, а с улицы нет — что я сделал не так?» Ничего. Так устроены домашние сети.
Внутренний адрес платы существует только внутри вашей квартиры. Роутер выпускает всех наружу под одним общим внешним адресом, но снаружи внутрь никого не пускает — это его работа. Поэтому из мобильного интернета или из другого города страница не откроется, и дело тут не в коде.
Соблазн решить это «пробросом порта» на роутере лучше сразу отложить. Проброс выставляет наружу устройство, у которого нет ни пароля, ни проверки прав, и такие устройства находят автоматически, за часы. Правильный способ дотянуться до поделки издалека — посредник, и ему посвящена следующая статья.
Чтобы адрес не менялся
Роутер выдаёт плате адрес в аренду, и после перезагрузки адрес может стать другим. Каждый раз лезть в монитор порта неудобно. Есть три способа.
- Закрепить адрес в роутере. Самый надёжный: в настройках находите плату по имени или по её аппаратному адресу и говорите «всегда выдавай ей вот этот». Плата при этом ничего не знает и скетч не меняется.
- Задать адрес в скетче методом
WiFi.config(). Работает, но требует аккуратности: адрес должен быть из диапазона вашей сети и не должен совпадать с чужим. - Дать плате имя вида
plata.local. Красиво, но ненадёжно: такие имена понимает не всякий телефон — Android научился этому только с двенадцатой версии, а многие роутеры режут нужную для этого групповую рассылку. Как запасной путь — годится, как основной — нет.
Когда роутера нет: плата раздаёт свою сеть
Отдельный и очень полезный режим: плата сама становится точкой доступа. Телефон видит её сеть в списке, подключается — и открывает панель. Интернета в такой сети нет, но для управления он и не нужен.
Так делают поделки для улицы, теплицы и автомобиля, а ещё — первичную настройку: плата поднимает свою сеть, вы заходите на страницу и вписываете туда имя и пароль домашней сети. Именно так настраиваются готовые умные розетки.
// Плата раздаёт свою собственную сеть Wi-Fi.
// Роутер не нужен вообще: телефон подключается прямо к плате.
// Так делают настройку «в поле» и поделки для улицы.
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESP8266WebServer.h>
ESP8266WebServer server(80);
#else
#include <WiFi.h>
#include <WebServer.h>
WebServer server(80);
#endif
// Имя сети, которую увидит телефон, и пароль к ней.
// Пароль обязан быть не короче восьми знаков, иначе сеть будет открытой.
const char AP_SSID[] = "Plata-Trema";
const char AP_PASS[] = "12345678";
void handleRoot() {
String page = "<!DOCTYPE html><html lang='ru'><head><meta charset='utf-8'>";
page += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
page += "<title>Плата</title></head><body style='font-family:sans-serif'>";
page += "<h1>Плата раздаёт свою сеть</h1>";
page += "<p>Подключено устройств: ";
page += String(WiFi.softAPgetStationNum());
page += "</p></body></html>";
server.send(200, "text/html; charset=utf-8", page);
}
void setup() {
Serial.begin(115200);
delay(200);
Serial.println();
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASS);
Serial.print("Сеть поднята. Адрес платы: ");
Serial.println(WiFi.softAPIP()); // почти всегда 192.168.4.1
server.on("/", handleRoot);
server.begin();
}
void loop() {
server.handleClient();
}
Адрес платы в этом режиме почти всегда 192.168.4.1. Пароль обязан быть не короче восьми знаков — иначе сеть получится открытой, и подключиться к вашей поделке сможет любой прохожий.
Главный враг сервера — delay()
Плата делает одно дело за раз. Пока программа стоит в delay(1000), она не вызывает server.handleClient() — а значит, не отвечает браузеру. Со стороны это выглядит как «страница подвисает», хотя плата совершенно здорова.
Правило простое: loop() должен пробегать за миллисекунды. Всё, что нужно делать «раз в секунду», делается по часам:
- запоминаем момент последнего действия в переменную и сравниваем с
millis()— приём разобран в уроках и в статье про сторож связи; - длинные вычисления режем на куски, а внутри тяжёлых циклов вызываем
delay(0)— это не пауза, а способ дать радио обслужить себя; - датчики, которые «думают» долго (например, ультразвуковой дальномер), опрашиваем не в каждом проходе, а по расписанию.
Отсюда же ответ на вопрос «сколько человек могут открыть страницу одновременно». Формально несколько, фактически — сервер обслуживает их по очереди, и если страница тяжёлая, второй посетитель заметит задержку. Для домашнего пульта это не проблема, для класса из тридцати учеников — уже да.
Честно про безопасность
Страница платы открыта всем, кто уже подключён к вашей сети. Никакой проверки прав в примерах выше нет — и это нормально для настольной лампы, но не для замка на двери.
Простейшую защиту дают встроенные средства: в начале обработчика вызывается server.authenticate("имя", "пароль"), и если проверка не прошла — server.requestAuthentication(). Браузер тогда сам покажет окошко с логином и паролем. Отдельная библиотека для этого не нужна.
Но иллюзий строить не стоит. Обычный веб-запрос идёт открытым текстом: тот, кто уже в вашей сети, при желании прочитает и пароль. Защищённое соединение плата тоже умеет, но браузер будет ругаться на самодельный сертификат, а на ESP8266 это ещё и заметно тяжело для памяти. Практический вывод: веб-панель на плате — это домашнее удобство, а не система безопасности. Для замков и ворот нужен другой подход.
Что делать, если не работает
| Что видно | Что на самом деле | Что сделать |
|---|---|---|
| Страница вообще не открывается, браузер пишет, что соединение отклонено | Сервер не запущен или плата не в сети | Проверьте, что в setup() есть server.begin(), а в мониторе порта напечатался адрес |
| Адрес набран, но браузер думает и сдаётся | Телефон и плата в разных сетях: гостевая сеть, другой диапазон, мобильный интернет | Оба устройства в одну и ту же сеть. Проверьте, что телефон не ушёл в мобильный интернет |
| Вместо русских букв кракозябры | Браузеру не сказали, в какой кодировке текст | Отдавайте тип «text/html; charset=utf-8» и держите в самой странице строчку meta charset |
| Страница открывается по несколько секунд | Плата молчит на запрос значка вкладки, и браузер ждёт до таймаута | Повесьте обработчик onNotFound — пусть коротко отвечает на всё незнакомое |
| Кнопка нажимается, но ничего не меняется | Обработчик не зарегистрирован или имя параметра в скетче другое | Сверьте адрес в кнопке и строку server.on(). Проверить можно прямо в адресной строке браузера |
| Страница есть, а показания не обновляются | loop() занят долгой работой и не вызывает handleClient() | Уберите delay(), считайте время по millis(). Правило: loop() должен пробегать за миллисекунды |
| Плата перезагружается, как только открываешь страницу | Страница собирается в оперативной памяти и не помещается | Перенесите её во флеш-память (PROGMEM и send_P) или разбейте на части |
| Страница обрывается на середине | Не хватило памяти на отправку или клиент отвалился | Уменьшите страницу, уберите картинки, отдавайте показания отдельным коротким запросом |
| С компьютера открывается, с телефона нет | На компьютере набрано имя вида «плата.local», а телефон такие имена не понимает | Откройте по числовому адресу и закрепите его за платой в настройках роутера |
| Работает несколько минут и перестаёт | Связь оборвалась, а скетч этого не заметил | Добавьте сторож связи из статьи про подключение к сети |
Что купить
Проверено 24 августа 2026 года: каждая карточка открыта отдельно, цена и артикул взяты со страницы товара. Того, чего нет в наличии, в подборке нет.
WeMos D1 mini на ESP8266
Для веб-панели этой платы хватает с запасом: страница с кнопками и парой показаний работает на ней годами. Самый дешёвый способ получить пульт в браузере.
Piranha ESP32 v2
Когда панель обрастает стилями, картинками и несколькими страницами, запас памяти ESP32 перестаёт быть роскошью. Плюс второе ядро: радио не мешает вашей программе.
Источник питания 5 В, 2 А
Панель, которая должна быть доступна круглосуточно, не может висеть на USB-порту компьютера. Отдельное питание с запасом — половина надёжности проекта.
Провода «папа-мама», 40 штук
Соединить плату с реле, датчиком или светодиодом. Длина 20 см, десять цветов — путаницы будет меньше.
Макетная плата на 830 точек
Собрать схему без паяльника: плата, кнопка, светодиод и пара резисторов держатся в ней сами.
Дальше по теме
- Wi-Fi для Arduino — как плата попадает в сеть и почему иногда не попадает.
- MQTT и умный дом — как управлять поделкой не только из дома.
- Прошивка по воздуху — менять страницу и программу, не доставая плату из корпуса.
- ESP8266, NodeMCU и Wemos D1 mini — установка ядра, распиновка и память.
- Проекты на Arduino — куда приспособить пульт в браузере.
Частые вопросы
Как узнать адрес платы, чтобы открыть её страницу?
Плата печатает его сама. После подключения к сети вызовите WiFi.localIP() и выведите результат в монитор порта — там появится строчка вида 192.168.1.47. Этот адрес и набирают в браузере телефона или компьютера. Если адрес каждый раз новый, закрепите его за платой в настройках роутера: там есть список подключённых устройств и кнопка «всегда выдавать этот адрес». Способ с именем вида «плата.local» тоже существует, но он ненадёжен: такие имена понимает не всякий телефон.
Почему страница платы не открывается с мобильного интернета?
Потому что внутренний адрес вида 192.168.х.х существует только внутри вашей домашней сети. Роутер выпускает устройства наружу под одним общим внешним адресом, но снаружи внутрь никого не пускает — это его прямая работа. Из мобильного интернета, с работы или из другого города страница не откроется, и дело не в коде. Пробрасывать порт на роутере не стоит: наружу окажется устройство без пароля и без проверки прав. Правильный способ дотянуться до поделки издалека — посредник, то есть MQTT или бот в мессенджере.
Почему вместо русских букв на странице кракозябры?
Браузеру не сказали, в какой кодировке текст. Нужны две вещи сразу: во-первых, отдавать тип «text/html; charset=utf-8» третьим аргументом в server.send(); во-вторых, держать внутри самой страницы строчку с указанием кодировки в разделе head. Если страница собирается из кусочков в переменной String, проверьте, что эта строчка попала в самое начало, до текста.
Можно ли поставить пароль на страницу платы?
Да, и отдельная библиотека для этого не нужна. В начале обработчика вызывается server.authenticate() с именем и паролем, а если проверка не прошла — server.requestAuthentication(): браузер сам покажет окошко для ввода. Только не считайте это настоящей защитой. Обычный веб-запрос идёт открытым текстом, и тот, кто уже подключён к вашей сети, при желании прочитает пароль. Веб-панель на плате — это домашнее удобство, а не система безопасности: для замков и ворот нужен другой подход.
Почему страница подвисает и открывается через раз?
Две обычные причины. Первая: в loop() стоит delay(), и пока программа спит, она не вызывает server.handleClient() — а значит, не отвечает браузеру. Правило простое: loop() должен пробегать за миллисекунды, а всё, что делается «раз в секунду», считается по millis(). Вторая: плата молчит на запрос значка вкладки, который браузер посылает сам, без вашего участия. Повесьте обработчик onNotFound, который коротко отвечает на всё незнакомое, — и страница начнёт открываться сразу.
Сколько устройств могут открыть страницу платы одновременно?
Формально несколько, фактически плата обслуживает их по очереди: она делает одно дело за раз. Для домашнего пульта это незаметно — страница отдаётся за доли секунды. А вот если панель тяжёлая, со стилями и картинками, а открывают её сразу человек десять, задержки станут видны. Для класса лучше отдавать страницу из флеш-памяти, убрать с неё всё лишнее и обновлять показания короткими запросами, а не перезагрузкой всей страницы.
Что для этого нужно в среде
Все три скетча собираются в Trema IDE без единой скачанной библиотеки: и сеть, и веб-сервер входят в поддержку самих плат. Поддержку ESP8266 и ESP32 среда берёт с нашего зеркала, поэтому установка не упирается в закрытые сайты.
Что почитать рядом: подключение к сети, плата ESP8266, монитор порта — он понадобится, чтобы узнать адрес платы, и разбор ошибок сборки.
