Телефонная панель из урока про MQTT Dash покрывала мне девяносто процентов управления, но на кухне случился свой сценарий: таймер плиты, статус окна и кнопка «всё выключить» должны были лежать на одном экране с логикой, которую в готовых виджетах не собрать. Веб-панель на самой плате — старое знакомое решение: страница живёт в ESP32, браузер ходит напрямую, без брокера и приложений между ними.
Вопрос, который я полгода откладывал: можно ли держать оба интерфейса сразу? Плате нужно слушать брокер — команды из дома — и одновременно отвечать браузеру: панель для гостей и для отладки. Оказалось, можно: ESP32 спокойно тянет два стека в одном скетче, если помнить две вещи. Состояние должно лежать в общих переменных, а цикл обязан обслуживать обоих клиентов без единого delay.
Урок про архитектуру: где живут переменные состояния, как выглядит страница, которая сама спрашивает плату о состоянии раз в две секунды, сколько RAM стоит одновременный веб-сервер с MQTT-клиентом и когда я всё-таки развожу интерфейсы по разным устройствам.
Два клиента, одна переменная состояния
Ключевое решение гибрида — единственный источник истины: глобальные переменные ledState, liters, uptime. HTTP-маршруты читают и пишут их, MQTT-callback пишет туда же, физическая кнопка на щитке меняет ту же переменную. Никаких копий «для веба» и «для брокера»: рассинхрон панелей почти всегда означает две переменные вместо одной. Механику кооперативного обслуживания нескольких дел в одном цикле мы крутили в уроке про кооперативный MQTT-скетч — здесь добавляется второй собеседник.
База — обычный WebServer из урока про веб-сервер ESP32: маршруты GET / со страницей и GET /api/state с JSON. В цикле рядом встают два обслуживания: server.handleClient() и client.loop(). Каждое занято своим собеседником: первое отвечает браузеру, второе — брокеру с его пингами и входящими командами. Пропуск любого из них на секунды стоит дорого: окно keepalive MQTT — пятнадцать секунд, а браузер, дождавшись таймаута, рисует страницу-лежак.
Публикацию состояния в брокер я оставил по старым правилам: по изменению или раз в минуту контрольной точкой тишины. Хитрость одна: пусть её дёргает сама переменная. Функция setLed() меняет ledState, щёлкает выходом и ставит флаг dirty; цикл видит флаг и публикует. Тогда и веб-панель, и кнопка, и брокер меняют мир через одну дверь, и никто не забывает уведомить остальных.
Гибрид даёт свойство, за которое я его и держу: изоляция отказов. Брокер на малине завис — веб-панель продолжает работать, потому что она ходит в переменные платы напрямую, мимо брокера. Лёг малиновый диск в момент готовки — кухня остаётся управляемой с телефона по адресу самой платы. Обратная изоляция честная: упала сама плата — панель умирает вместе с ней, и тут никакая архитектура не поможет, железо есть железо.
// WebServer + PubSubClient: оба обслуживаются в каждом проходеWebServer server(80);int ledState = 0;bool dirty = false;unsigned long lastPub = 0, lastBeat = 0; void setLed(int v) { // единственная дверь к состоянию ledState = v ? 1 : 0; digitalWrite(12, ledState ? HIGH : LOW); dirty = true; // цикл увидит и опубликует} void publishState() { // "home/hall/light" = {"led":1} StaticJsonDocument<128> doc; doc["led"] = ledState; char buf[32]; serializeJson(doc, buf); client.publish("home/hall/light", buf, true);} void loop() { server.handleClient(); // браузер: страница и /api/state client.loop(); // брокер: пинги, подписки, команды unsigned long ms = millis(); if (dirty && ms - lastPub > 300) { // слить очередь запросов в одну публикацию lastPub = ms; dirty = false; publishState(); } if (ms - lastBeat >= 60000) { // контрольная точка тишины lastBeat = ms; publishState(); }}Пауза в триста миллисекунд между флагом и публикацией — не украшение: без неё три запроса залпом от панели породили бы три пакета с одним значением, а с ней уходит один, последний. Обратите внимание, что в скелете нет ни одного delay: браузеру, который качает страницу по Wi-Fi, handleClient нужен сотни раз в секунду, иначе сборка ответа растянется на секунды. Полный разбор неблокирующего каркаса — в уроке про кооперативный цикл, а сериализация в char-буфер вместо String экономит фрагментацию кучи.
Про память честно и с числами. У ESP32 около 520 КБ SRAM, приложению достаётся порядка 300 КБ; стек Wi-Fi откусывает свои 50-80 КБ ещё до вашего setup(). Дальше набегает: буфер публикации PubSubClient — 256 байтов по умолчанию, буферы соединений WebServer, страница в String — ещё килобайт-другой. Всё это помещается, пока не включается TLS: защищённое MQTT-соединение добавляет 40-50 КБ под рукопожатие и буферы, и гибрид с TLS уже требует постоянного взгляда на heap. На Uno с двумя килобайтами такой узел невозможен физически.
Страница, которая спрашивает сама
Панель собираю из двух кусков: статичный HTML держу в PROGMEM или сырой строкой, как в уроке про HTML из скетча, а состояние страница добирает сама — JS-таймер раз в две секунды стучится в /api/state и перекрашивает страницу. Маршрут /api/state отдаёт те же переменные, что уезжают в брокер: панель в браузере и панель в телефоне всегда показывают одно и то же, потому что читают один источник.
Опрос по таймеру — сознательный выбор из двух дорог. WebSocket и Server-Sent Events дают мгновенный пуш, но это ещё один живой клиент на плату с его памятью и обработчиками — отдельный урок соседнего раздела. Для панели из трёх плиток интервал в две секунды я выбираю спокойно: плата отдаёт JSON за миллисекунды, а простота окупается стабильностью. Проверить опрос можно и без браузера: mosquitto_pub в командный топик — панель перекрасится на ближайшем тике таймера.
Панели нужен стабильный адрес. IP от роутера я закрепляю привязкой по MAC, но красивее mDNS: плата называет себя hall.local, и в браузере адрес не зависит от раздачи адресов — настройка занимает три строки и разобрана в уроке про mDNS. Для гостей это удобнее всего: скажи «наберите hall.local» вместо диктовки четырёх чисел.
Про безопасность без иллюзий: панель без пароля живёт только в домашней сети. Пробрасывать её порт на роутере наружу я категорически не советую — так вы публикуете весь дом одним портом; если доступ извне нужен, это задача отдельного входа с авторизацией или VPN. Брокерскую сторону защищают паролями и правилами доступа, что разобрано в уроке про пароли и ACL mosquitto — веб-стороне нужен хотя бы Basic Auth на маршруты записи.
<!-- фрагмент страницы: статус и кнопка, состояние добирается само --><div id="st">...</div><button id="btn">Вкл</button><script>document.getElementById("btn").addEventListener("click", function() { fetch("/api/led=1", { method: "POST" });});setInterval(function() { fetch("/api/state") .then(function(r) { return r.json(); }) .then(function(s) { document.getElementById("st").textContent = s.led ? "Вкл" : "Выкл"; });}, 2000);</script>Панель оживает в три приёма: addEventListener вешает POST на кнопку, setInterval с шагом 2000 стучится в /api/state, а fetch кладёт ответ в элемент с id=st. Кнопка в панели постит на /api/led=N, маршрут меняет ту же переменную через setLed() — и флаг dirty отправит новое значение в брокер. Круг замкнулся: любой из трёх входов — браузер, брокер, физическая кнопка — меняет состояние, а оба выхода — страница и MQTT — показывают его без ручной синхронизации.
Гибрид стоит проверять и от обратного: остановите mosquitto на малине и откройте панель — страница отвечает, кнопки работают, а в мониторе платы сыпятся попытки reconnect по правилам урока про рестарт без потерь. Поднимите брокера — клиент вернётся, подписки восстановятся, а панель за это время не заметила ничего. Это и есть изоляция отказов вживую: пауза в работе одного интерфейса не глушит второй.
Граница приёма — масштаб панели. Один узел с двумя-тремя плитками живёт в гибриде годами: RAM хватает, питание одно, прошивка одна. Как только панель претендует на центр дома — десятки плиток, история, графики, — она должна жить на отдельном сервере, а узлы остаются клиентами: плата-гибрид в роли центра — это точка отказа всего жилища, и я не готов платить такой ценой за один SD-картой меньше в схеме.
И ещё один довод из практики: гибрид — лучший стенд для отладки. Пока дом ещё не собран, страница на плате показывает всё состояние узла без брокера, без приложений и без кода на стороне сервера: открыл браузер — увидел, нажал — проверил. Когда узлов становится больше, панель уезжает на отдельную машину, а привычка держать /api/state на каждом узле остаётся: диагностический маршрут стоит трёх строк кода и экономит вечер на каждом новом устройстве.
| Сценарий | Мой выбор | Почему |
|---|---|---|
| Один узел, 2-3 клиента в панели | гибрид на ESP32 | RAM хватает, один блок питания и одна прошивка |
| Панель на весь дом, 10+ плиток | панель отдельно: HA или телефон | плата-гибрид станет точкой отказа всего дома |
| MQTT через TLS плюс панель | разнести или следить за heap | 40-50 КБ на TLS поверх Wi-Fi и веб-сервера |
| Гостям нужна кнопка без приложений | гибрид: страница без установки | браузер — универсальный клиент |
| Мгновенный пуш в браузер | WebSocket или отдельный сервис | опрос по таймеру тянет не каждую задачу |
| Плата пропала из сети | панель покажет устаревший снимок | опрос таймером не заменяет LWT-индикатор |
- Пошлите GET /api/state два раза подряд с паузой: JSON в ответе показывает живое состояние — так выглядит тик JS-таймера панели.
- Пошлите POST /api/led=1: светодиод зажглся, в мониторе ушла публикация в брокер — флаг dirty сработал от веб-маршрута.
- Нажмите кнопку на макетке: состояние перевернётся от лица кнопки, а панель и брокер узнают об этом на ближайшей публикации.
- Пошлите GET /api/state сразу после кнопки: ответ уже показывает новое значение — страница и брокер читают ту же переменную.
- Гибрид — одна переменная состояния и три входа: браузер, брокер, физическая кнопка
- server.handleClient() и client.loop() обслуживаются в каждом проходе цикла, без единого delay
- Публикацию дёргает флаг dirty из setLed(): одна дверь к состоянию — ноль рассинхрона
- Цена гибрида: Wi-Fi 50-80 КБ, буферы сервера и клиента, TLS добавит 40-50 КБ; на Uno не поместится
- Опрос страницы JS-таймером раз в 2 секунды — простой вариант из двух; мгновенный пуш — WebSocket
- 1. Почему переменную состояния меняют через одну функцию setLed(), а не напрямую из обработчиков?
- 2. Что случится с MQTT-соединением, если в цикле гибрида появился delay(2000)?
- 3. Сколько SRAM примерно остаётся приложению ESP32 после загрузки?
- 4. Как страница панели узнаёт актуальное состояние без перезагрузки?
- 5. Какой сценарий — довод против гибрида?
Частые вопросы по теме урока
- Почему переменную состояния меняют через одну функцию setLed(), а не напрямую из обработчиков?
- Единая дверь к состоянию гарантирует, что любой вход — браузер, брокер, кнопка — уведомит остальных. Копии переменных для каждого интерфейса рассинхронизируются. Функция ставит флаг dirty, и публикация уходит во все интерфейсы
- Что случится с MQTT-соединением, если в цикле гибрида появился delay(2000)?
- Окно keepalive — пятнадцать секунд, но дело не только в нём: за две секунды delay опаздывают и команды, и браузер. Каркас гибрида строится без delay. Соединение порвётся: client.loop() не обслуживал пинги
- Сколько SRAM примерно остаётся приложению ESP32 после загрузки?
- Из 520 КБ SRAM часть занята системой и стеком Wi-Fi (порядка 50-80 КБ): приложению достаётся порядка 300 КБ. TLS-соединение откусит ещё 40-50 КБ. около 300 КБ из 520
- Как страница панели узнаёт актуальное состояние без перезагрузки?
- Опрос по таймеру — простой каркас: fetch читает JSON из тех же переменных, что уезжают в брокер. Мгновенный пуш дают WebSocket, но это отдельная цена. JS-таймер раз в 2 секунды читает /api/state
- Какой сценарий — довод против гибрида?
- Если панель становится центром дома, плата-гибрид превращается в точку отказа всего жилища. Большую панель выносят на отдельный сервер, узел остаётся клиентом. Панель на весь дом с десятками плиток и узлов