Табло температуры в коридоре у меня жило на опросе: страница раз в две секунды сама перезапрашивала /status — сначала через мета-тег перезагрузки, потом аккуратнее, скриптом. Работало, но выглядело дёшево: страница мигала при каждом обновлении, цифра прыгала, а Serial показывал непрерывную очередь запросов. Я посчитал: каждый опрос — это полный HTTP-запрос с сотней-другой байт заголовков, чтобы забрать двадцать байт данных. Десять опросов в минуту на каждый открытый телефон — и плата значительную часть жизни тратила на перекачку заголовков.
WebSocket закрывает это другими отношениями. Браузер и плата один раз проходят рукопожатие — обычный HTTP-запрос со словом Upgrade, — и после ответа 101 Switching Protocols соединение не закрывается: по нему в обе стороны летят короткие кадры. Плата может слать данные сама, без запроса, раз в секунду или когда показание поменялось, а страница обновляет цифру точечным изменением текста — без перезагрузки и без мигания. В этом уроке подключаем AsyncWebSocket из библиотеки, которую мы уже поставили в уроке про асинхронный сервер, шлём живой JSON и считаем бюджет трафика.
Рукопожатие один раз, кадры потом
Роль обычных маршрутов и канала данных удобно разделять: HTTP-маршрут / отдаёт страницу с JavaScript-клиентом, а WebSocket живёт по адресу /ws как отдельный обработчик. В коде это два объекта: AsyncWebServer для страницы и AsyncWebSocket ws("/ws") для канала; канал подключается через server.addHandler(&ws). События соединений приходят в функцию onWsEvent: кто подключился (WS_EVT_CONNECT), кто отвалился (WS_EVT_DISCONNECT) — по ним удобно вести журнал и индикатор клиентов.
Само рукопожатие выглядит как обычный HTTP-запрос с парой особых заголовков: браузер просит Upgrade: websocket и присылает случайный ключ, плата отвечает 101 Switching Protocols и эхом подтверждает ключ — после этого оба переходят на кадры. Всё это библиотека делает за вас, но понимать механику полезно: пока рукопожатие не прошло, ws.count() клиента не покажет, а если адрес канала в скетче и в JS-клиенте различаются хотя бы регистром, соединение отклоняется молча. Я один вечер писал в скетче путь с большой буквы, а в браузере с маленькой — и страница вежливо молчала: регистр в адресе канала значим, а сообщений об этом нет.
Рассылка данных — один вызов: ws.textAll(json) кладёт строку во все открытые соединения сразу. Внутри страницы живёт маленький клиент: new WebSocket("ws://адрес-платы/ws") открывает канал, ws.onmessage ловит кадры, и каждая пришедшая строка обновляет div на странице. Никакого setTimeout-опроса: страница просто слушает. Разбирать пришедший JSON на приёме вы уже умеете по уроку про разбор JSON — принципы те же, только теперь кадры летят и в обратную сторону: так же легко с телефона прилетает команда на светодиод.
В JS-клиенте держите три обработчика, а не один. ws.onmessage обновляет цифру; ws.onopen зажигает на странице индикатор связи — зелёная точка стоит часа отладки; ws.onclose гасит её и запускает переподключение через setTimeout на пару секунд. Переподключение — не паранойя: плата перезагрузится, адрес сменится после перезапуска роутера, телефон уснёт и проснётся в лифте — страница, которая умеет сама восстанавливать канал, живёт неделями без вашего участия. Готовый канал проверяют свойством readyState — единица значит OPEN, и слать в закрытый канал бессмысленно.
Бюджет, из-за которого всё затевалось. Опрос раз в две секунды: запрос с заголовками браузера — порядка 300 байт на каждый, тридцать запросов в минуту — около 9 КБ чистого служебного трафика на клиента, плюс плата должна успевать принимать и отвечать. Кадр WebSocket с той же парой чисел — {"t":24.3,"h":41} — это два с половиной десятка байта, и в минуту их набирается около полутора килобайт при вдвое большей частоте обновления. Разница не в байтах ради байтов: плата не просыпается на каждый опрос, а просто пишет в уже открытую трубу.
#include <WiFi.h>#include <ESPAsyncWebServer.h> AsyncWebServer server(80);AsyncWebSocket ws("/ws"); // канал данных по адресу /wsunsigned long lastSend = 0; void onWsEvent(AsyncWebSocket *, AsyncWebSocketClient *client, AwsEventType type, void *, uint8_t *, size_t) { if (type == WS_EVT_CONNECT) { Serial.print("Клиент #"); Serial.print(client->id()); Serial.println(" подключился"); } if (type == WS_EVT_DISCONNECT) { Serial.print("Клиент #"); Serial.print(client->id()); Serial.println(" отключился"); }} void setup() { Serial.begin(115200); WiFi.begin("DachaNet", "superParol123"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(WiFi.localIP()); ws.onEvent(onWsEvent); server.addHandler(&ws); // канал прицеплен к серверу server.on("/", HTTP_GET, [](AsyncWebServerRequest *r) { String page = "<h1>Живая температура</h1><div id='v'>--</div>" "<script>const ws = new WebSocket('ws://' + location.host + '/ws');" "ws.onmessage = (e) => { document.getElementById('v').textContent = e.data; };" "</script>"; r->send(200, "text/html", page); }); server.begin();} void loop() { if (millis() - lastSend >= 1000) { // раз в секунду lastSend = millis(); ws.cleanupClients(); // прибираем мёртвые соединения if (ws.count() > 0) { // шлём, только если слушают float t = 20.0 + analogRead(A0) * 15.0 / 1023.0; String frame = "{\"t\":" + String(t, 1) + "}"; ws.textAll(frame); // всем клиентам сразу Serial.print("Кадр: "); Serial.println(frame); } }}Две строки в loop() несут всю гигиену канала. ws.cleanupClients() выметает соединения, которые умерли незаметно — телефон уснул, экран закрылся, — и без неё счётчики клиентов врут, а память подтекает. ws.count() возвращает число живых слушателей: если их ноль, кадр и не собирается — зачем греть процессор, когда страница закрыта. Сборка кадра String-конкатенацией здесь допустима, потому что кадр крошечный и редкий; если данных станет заметно больше, присмотритесь к статическому буферу — постоянные сшивания строк фрагментируют кучу.
Два приёма, которые я добавил ко второму табло и не жалею. Первый — слать кадр только при изменении: показание сравнивается с прошлым, и если температура не сдвинулась хотя бы на десятую долю градуса, кадр не собирается вовсе; в спокойной комнате трафик падает в разы, а страница ничего не теряет — последнее значение и так на экране. Второй — кадры в обратную сторону: с телефона прилетает "led:on", обработчик ws-события читает данные и переключает светодиод; канал один, а команд и данных по нему ездит сколько нужно — двусторонность здесь не украшение, а рабочая механика.
Число клиентов я держу в голове всегда: по умолчанию канал рассчитан на несколько одновременных соединений, и каждый кадр textAll уходит каждому. Для табло в коридоре и пары телефонов этого хватает с запасом. Если же данных становится поток — телеметрия раз в сто миллисекунд, история показаний — переходите на MQTT из урока про публикацию показаний: там брокер берёт на себя и очередь, и переподключение, и хранение последних значений.
| Опрос по HTTP (таймер или F5) | WebSocket | |
|---|---|---|
| Кто инициирует данные | Браузер спрашивает сам, раз в интервал | Плата шлёт сама, когда есть что |
| Расход на обновление | Заголовки, порядка 300 байт на запрос | Кадр с t и h — около 25 байт |
| Задержка появления данных | До следующего опроса — до 1-2 с | Миллисекунды после отправки кадра |
| Соединение | Открывается и закрывается каждый раз | Одно на всю сессию страницы |
Кадры в симуляторе: поток, который уходит в браузер
WebSocket-канал движок симулятора не поднимает — зато мы можем увидеть точный текст кадров и почувствовать ритм. Таймер на millis() раз в секунду читает регулятор на A0, собирает JSON-документ ArduinoJson и печатает кадр в Serial — это в точности та строка, которую на живой плате получил бы браузер через ws.onmessage. Поверните регулятор между кадрами: следующая строка придёт уже с новым значением, и никакой перезагрузки не случилось. Светодиод здесь играет роль индикатора открытого соединения: горит — слушатель на линии. Рядом с кадрами печатается счётчик байт каждого кадра — сравните его с тремя сотнями байт заголовков, которые ушли бы на каждый опрос по HTTP, и бюджет из таблицы ниже перестанет быть абстракцией.
- 1. Сколько рукопожатий делает WebSocket за час открытой страницы с кадром раз в секунду?
- 2. Чем ws.textAll(json) отличается от отправки одному клиенту?
- 3. Зачем вызывать ws.cleanupClients() в loop()?
- 4. Что получит браузер в обработчике ws.onmessage, если плата шлёт {"t":24.3}?
- 5. Что вернее про трафик: опрос раз в 2 с или кадр WebSocket раз в 1 с?
- WebSocket — одно рукопожатие (HTTP Upgrade, ответ 101), потом короткие кадры в обе стороны без перезагрузок
- Канал: AsyncWebSocket ws("/ws"), ws.onEvent(onWsEvent), server.addHandler(&ws); страница — обычным маршрутом
- Гигиена loop(): ws.cleanupClients() раз за проход; кадры шлём ws.textAll(json) только при ws.count() > 0
- На странице: new WebSocket("ws://адрес/ws") и ws.onmessage — e.data обновляет текст точечно, без мигания
- Кадр {"t":24.3} — пара десятков байт против ~300 байт заголовков на каждый опрос по HTTP
Раздел теперь замкнулся на живой петле: плата раздаёт маршруты, обслуживает десяток клиентов и сама толкает данные в браузер, пока тот просто слушает. Логичный следующий шаг на железе — заменить регулятор настоящим DHT22 и слать кадры с его показаниями: скетч урока меняется на четыре строки. Когда данных станет больше, чем нужно одному экрану, — урок про MQTT покажет, как раздавать телеметрию всему дому через брокера. А моё правило напоследок: канал данных держите отдельным от страницы — /ws для кадров, / для HTML, и никогда не смешивайте: И про частоту последняя цифра с условием: кадр раз в секунду — для табло за глаза; если шлёте чаще раза в сто-двести миллисекунд, сначала проверьте, что данные реально меняются, — постоянные одинаковые кадры жгут эфир и место в буферах, а пользы читателю не приносят.
Частые вопросы по теме урока
- Сколько рукопожатий делает WebSocket за час открытой страницы с кадром раз в секунду?
- После HTTP-Upgrade и ответа 101 соединение не закрывается. Все кадры идут по нему без повторных запросов и заголовков. Одно: канал открывается один раз и живёт всю сессию
- Чем ws.textAll(json) отличается от отправки одному клиенту?
- textAll рассылает кадр всем живым слушателям канала. Для адресной отправки конкретному клиенту есть его собственный метод send. Кладёт строку во все открытые соединения сразу
- Зачем вызывать ws.cleanupClients() в loop()?
- Клиенты исчезают без прощания — сон телефона, закрытая вкладка. cleanupClients() выметает мёртвые соединения, иначе счётчик врёт и память подтекает. Чтобы выметать мёртвые соединения — уснувшие телефоны числятся клиентами и съедают память
- Что получит браузер в обработчике ws.onmessage, если плата шлёт {"t":24.3}?
- Кадр приходит строкой в e.data. Скрипт страницы сам превращает её в числа — например, через JSON.parse — и обновляет текст на странице. Строку {"t":24.3} в поле e.data — её разбирает JS на странице
- Что вернее про трафик: опрос раз в 2 с или кадр WebSocket раз в 1 с?
- Около 300 байт заголовков на каждый опрос против кадра в ~25 байт. Даже удвоенная частота кадров оставляет WS примерно в пять раз экономнее на клиента. WS экономнее даже при вдвое большей частоте: кадр — десятки байт против сотен байт заголовков