Табло счётчика воды в котельной я когда-то кормил через WebSocket из урока про живые показания и чувствовал себя излишне вооружённым: одно число в две секунды, а на плате висит двусторонний канал со своей памятью и обработчиками. Когда понадобилась вторая табло, я попробовал Server-Sent Events — и оставил их на половине своих панелей: поток «сервер-браузер» поверх самого обычного HTTP, без апгрейдов протокола, а переподключение браузер делает сам.
Разберём формат кадра, AsyncEventSource из библиотеки, которую мы ставили в уроке про асинхронный сервер, и границы технологии: куда идут команды вместо канала и почему пятая вкладка может замолчать. Браузера у симулятора нет — сам поток рисуется моделью в Serial с честной пометкой.
Кадр: data, event, id, retry
Механика на удивление скучная — в этом её сила. Браузер запрашивает /events обычным GET, плата отвечает с заголовком text/event-stream и не закрывает соединение, а дописывает в него кадры. Кадр — несколько строк и обязательно пустая строка в конце: «data: 24.3» плюс пустая строка — событие доставлено; строка «event: temp» даёт событию имя; «id: 1739» помечает номер для продолжения после обрыва; «retry: 3000» подсказывает, через сколько миллисекунд браузеру переспросить, если связь умерла. Ответ при этом — обычный HTTP 200 на том же порту 80: ни апгрейда, ни второго порта, и потому файрволы с точками доступа не видят причин мешать потоку.
Клиентская сторона — десяток JavaScript-строк: new EventSource("/events") открывает канал, onmessage ловит безымянные события, addEventListener("temp", ...) — именованные. Главное свойство достаётся бесплатно: разрыв связи браузер переживает сам, переспрашивая адрес через retry-интервал, — никакой обвязки с setTimeout из WebSocket-урока писать не нужно. Панель, которую я собирал на брокере в уроке про гибрид веб-панели и MQTT, умеет то же самое, но там за мгновенный пуш платим живым брокером.
Два адреса в этом хозяйстве держите раздельно: страница живёт на /, канал — на /events, и путь в скетче и в строке new EventSource должен совпасть байт в байт, включая регистр. Заголовок text/event-stream библиотека поставит сама, а содержимое кадров — целиком ваша забота: это просто строки в формате выше. Оттого SSE безболезненно проходит через прокси и корпоративные шлюзы, которые режут WebSocket: для сети это обычный долгий HTTP-ответ, и только пустая строка между кадрами подсказывает браузеру, где закончилось событие.
#include <WiFi.h>#include <ESPAsyncWebServer.h> AsyncWebServer server(80);AsyncEventSource events("/events"); // канал SSE по адресу /events unsigned long tKadr = 0;float temp = 21.0; void setup() { Serial.begin(115200); WiFi.begin("DachaNet", "superParol123"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(WiFi.localIP()); events.onConnect([](AsyncEventSourceClient *client) { Serial.println("sse: klient podklyuchilsya"); }); events.onDisconnect([](AsyncEventSourceClient *client) { Serial.println("sse: klient otvalilsya"); }); server.addHandler(&events); // канал прицеплен к серверу server.on("/", HTTP_GET, [](AsyncWebServerRequest *r) { String page = "<div id=\"v\">--</div>" "<script>const es = new EventSource(location.origin + \"/events\");" "es.addEventListener(\"temp\", (e) => {" "document.getElementById(\"v\").textContent = e.data + \" C\"; });" "</script>"; r->send(200, "text/html", page); }); server.begin();} void loop() { if (millis() - tKadr > 2000) { tKadr = millis(); temp = 21.0 + (analogRead(34) % 80) / 10.0; // условное показание String kad = String(temp, 1); events.send(kad.c_str(), "temp", millis()); // кадр с именем temp }}Разбор. events.send получает текст, имя события и метку id — из этого библиотека собирает кадр и дописывает всем открытым клиентам; onConnect и onDisconnect отдают журнал в Serial, по ним видно, кто слушает. Команды из браузера на плату через SSE не идут — канал читается только в одну сторону: каждый клик «включить» превращается в обычный HTTP-маршрут, как в первом веб-сервере, а страница точечно правит свой div. Этим SSE и отличаются от WebSocket: там двусторонняя труба, здесь — водосток, и для панелей наблюдения водостока достаточно.
Сколько клиентов выдержит плата — вопрос памяти, и он честнее решается экспериментом: две-три вкладки мой шлюз держит незаметно, дюжина — уже повод мерить свободную оперативку печатями и снижать частоту. onDisconnect снимает клиента из списка, а send без единого слушателя стоит копейки — не надо городить условия «есть ли кто-то». Единственная привычка, которую стоит завести, — журнал подключений в Serial: по нему видно, кто и когда слушает, а кто только сделал закладку на потом.
Что именно слать — второй вопрос после «как». Показания агрегируйте на плате: кадр — когда значение изменилось больше шага, плюс контрольный тик раз в минуту; страница остаётся живой без потока мусора. В data возите то, что странице удобно съесть: моё число — просто «24.3», а для трёх-четырёх значений удобнее JSON-строка, разбор на стороне браузера — десяток строк. Частоту задаёт millis-расписание из урока про millis вместо delay: у табло раз в две секунды, у графика — накопительное раз в минуту.
Частоту держите честной и к плате: send на каждое дрожание ползунка захламляет канал десятками кадров в секунду и множит копии в каждой вкладке. Тот же шаг изменения, что в Serial-протоколах, — и здесь экономит память и вкладки: кадр уходит, когда показание уехало больше порога, плюс контрольный тик в минуту.
Лимиты, вкладки и разрывы
Первый потолок — браузерный. По HTTP/1.1 браузер держит шесть живых соединений на домен, и каждая вкладка с SSE занимает одно: шестая вкладка может молчать, а открытые инструменты разработчика съедают ещё одно. HTTP/2 умеет мультиплексировать, но плата на нём не разговаривает. Для дачной панели это неважно, для табло с дюжиной мониторов — уже важнее: либо режьте частоту, либо переходите на WebSocket, либо ставьте перед платой настоящий сервер. Считается по-простому: панель в двух окнах — два соединения, DevTools с открытой вкладкой сети — ещё одно. Когда лимит исчерпан, браузер начинает отказывать картинкам и стилям: я однажды полчаса лечил «сломанную вёрстку», а виновата была шестая вкладка с потоком.
Разрывы: роутер перезагрузился, телефон уснул в лифте, WiFi платы ушёл в reconnect по собственному циклу из урока про переподключение. Браузер всё это время переспрашивает /events сам — и как только плата вернётся в сеть, поток продолжится. Метки id делают продолжение аккуратным: клиент шлёт заголовком последний полученный номер, и сервер, если умеет, досылает пропущенное; для показаний датчиков пропуски не страшны, но механику знать стоит, когда EventSource упрётся в предел попыток. А чтобы страницу находить без IP-адресов, пригодится mDNS — имя платы .local.
Практика разрывов, собранная за зиму: роутер падает в три ночи, плата по reconnect-циклу возвращается через минуту, браузер за это время делает пяток попыток и продолжает слушать — к утру страница свежая, никто никуда не ходил. Хуже ведёт себя телефон в режиме экономии: браузер замораживает вкладку, и EventSource замирает вместе с ней — при пробуждении поток догоняет без вашего участия. И ещё одно отличие от WebSocket, за которое я держусь: обновление страницы по F5 создаёт новый EventSource, а старое соединение закрывается само — без «фантомных» клиентов, о которых в WebSocket-уроке была отдельная заметка.
| Способ | Направление | Переподключение | Цена для платы |
|---|---|---|---|
| Опрос по таймеру | клиент спрашивает | само, новым запросом | нулевая, но заголовки на каждый тик |
| SSE | плата шлёт, клиент читает | автоматом EventSource по retry | живое соединение на клиента |
| WebSocket | обе стороны | обвязкой в JS-клиенте | выше: канал и его буферы |
Итог: SSE — односторонний поток событий поверх обычного HTTP: страница открыта, кадры прилетают сами, разрывы браузер переживает без вашего участия. На плате это один обработчик AsyncEventSource и отправка по расписанию — дешевле двустороннего WebSocket, когда обратно идти нечему. Буферы тут скромные: один send — один кадр, и то, что не доехало при разрыве, исчезает безвозвратно — правильная модель для показаний и неправильная для журналов. Команды — обычными маршрутами, обновления — потоком: пара примитивов закрывает всю панель.
Домашнее задание: добавьте к скетчу второе именованное событие press со счётчиком нажатий кнопки на GPIO 4 и ловите его на странице отдельным addEventListener — вы сразу увидите, как два потока живут в одном соединении и как страница переживает закрытие вкладки. Если страница должна переживать и сон телефона, добавьте в JS обработчик onerror с печатью в консоль — ритм попыток переподключения станет виден глазами.
- 1. Что завершает кадр SSE?
- 2. Соединение SSE оборвалось из-за перезагрузки роутера. Что делает браузер?
- 3. Почему шестая вкладка с SSE может не получать поток?
- 4. Как браузер отправляет команду на плату, если показания идут через SSE?
- 5. Что делает строка retry: 3000 в потоке?
- SSE — поток «плата → браузер» поверх обычного HTTP: кадры дописываются в незакрываемый ответ
- Кадр — строки data/event/id/retry и пустая строка-разделитель; без неё событие не доставляется
- EventSource переподключается сам: retry задаёт интервал, id — метка продолжения потока
- Команды в обратную сторону — обычные HTTP-маршруты; SSE только читается
- Лимит HTTP/1.1 — шесть живых соединений на домен: пятая-шестая вкладка рискует остаться без потока
Частые вопросы по теме урока
- Что завершает кадр SSE?
- Событие — это строки data/event/id/retry и обязательно пустая строка-разделитель. Без неё браузер ждёт продолжения, и кадр не считается доставленным. Пустая строка
- Соединение SSE оборвалось из-за перезагрузки роутера. Что делает браузер?
- EventSource переподключается сам — по интервалу из retry или по своему внутреннему расписанию. Ни setTimeout, ни обвязки из WebSocket-клиента писать не нужно. Сам переподключается и продолжает слушать
- Почему шестая вкладка с SSE может не получать поток?
- Шесть живых соединений на домен — старое правило HTTP/1.1, и каждое SSE-соединение его расходует. Инструменты разработчика занимают ещё одно. По HTTP/1.1 браузер держит шесть живых соединений на домен
- Как браузер отправляет команду на плату, если показания идут через SSE?
- SSE читается только в одну сторону — от платы. Каждая кнопка страницы это обычный GET или POST на маршрут, как в первом веб-сервере раздела. Обычным запросом на HTTP-маршрут
- Что делает строка retry: 3000 в потоке?
- retry — подсказка клиенту о паузе между попытками переподключения в миллисекундах. Частота кадров задаётся расписанием на плате, а не этой строкой. Просит браузер переподключаться через три секунды