ESP32 и MQTT: умный дом без облака
Брокер, темы, retain и последняя воля — как устройства договариваются между собой через одну программу в домашней сети
Лампа, термостат и ночник на ESP32 могут общаться между собой и с телефоном без чужих серверов. Разбираем MQTT на трёх примерах: темы, подписки, сохранённые сообщения, последнюю волю и ошибки, из-за которых связь рвётся.
Готовые «умные» лампы и розетки обычно работают через облако производителя. Команда с телефона уходит на сервер в другой стране и возвращается обратно в квартиру, до которой было три метра. Пропал интернет — выключатель в приложении перестаёт работать. Закрыл производитель сервис — устройство превращается в обычную лампу.
Устройства на ESP32 можно связать иначе: через брокер MQTT в своей домашней сети. Брокер — одна программа на любом постоянно включённом компьютере, хоть на старом ноутбуке или одноплатнике. Датчики публикуют в него показания, исполнительные устройства подписываются на команды, телефон или панель управления делают и то и другое. Интернет для этого не нужен.
Как устроен MQTT: брокер, темы, подписки
В MQTT устройства не обращаются друг к другу напрямую. Каждое подключается к брокеру по TCP, по умолчанию на порт 1883, и дальше делает две вещи: публикует сообщения в темы и подписывается на темы, которые ему интересны. Брокер рассылает каждое опубликованное сообщение всем, кто подписан на его тему. Отправитель не знает, сколько получателей и есть ли они вообще.
Тема — строка с уровнями через косую черту, вроде pinport/room/climate. Подписка может содержать маски:
+заменяет ровно один уровень:pinport/+/statusловитpinport/room/statusиpinport/demo/status;#в конце заменяет все уровни ниже:pinport/#ловит всё, что начинается сpinport/.
Хорошая схема тем повторяет устройство дома: место, прибор, величина. Отдельные темы для команд и для состояния избавляют от путаницы: в pinport/demo/led пишут команду, из pinport/demo/led/state читают, что получилось.
Сообщение — это последовательность байтов. MQTT не знает ни о числах, ни о JSON. Договорённость о формате — дело ваших устройств: пример с лампой понимает текст on, off, toggle, термостат публикует JSON вида {"t":21.5,"h":40.0}.
Лампа и кнопка: подключение и обработчик сообщений
На ESP32 для MQTT чаще всего берут библиотеку PubSubClient. Она работает поверх любого Client, в данном случае WiFiClient. После подключения к WiFi указывается брокер и функция, которая будет получать сообщения:
WiFiClient net;
PubSubClient mqtt(net);
mqtt.setServer("broker.hivemq.com", 1883);
mqtt.setCallback(onMessage);
Подключение к брокеру делается в loop() и повторяется, если связь пропала. В примере с лампой и кнопкой попытка идёт раз в пять секунд и не блокирует цикл:
bool connectMqtt() {
String id = "pinport-" + String((uint32_t)ESP.getEfuseMac(), HEX);
if (!mqtt.connect(id.c_str(), T_STATUS, 0, true, "offline")) {
Serial.printf("не вышло, код %d\n", mqtt.state());
return false;
}
mqtt.publish(T_STATUS, "online", true);
mqtt.subscribe(T_LED);
setLed(ledOn);
return true;
}
Первый аргумент connect — идентификатор клиента. Он должен быть уникальным: брокер, увидев второе подключение с тем же идентификатором, закрывает первое. Поэтому пример собирает его из заводского MAC-адреса платы. Остальные четыре аргумента — последняя воля, о ней ниже.
Подписка ставится после каждого подключения, а не один раз в setup(): при чистом сеансе брокер не помнит подписок разорванного соединения.
Входящие сообщения приходят в onMessage, но только во время вызова mqtt.loop(). Именно он читает сокет, отвечает на служебные пакеты и вызывает обработчик. Если loop() надолго застрял в delay(), сообщения ждут в буфере.
MQTT: лампа и кнопка
Снимите галочку «запускать со сценарием» и запустите. Во вкладке «MQTT» найдите подписку платы на pinport/demo/led, щёлкните по ней и опубликуйте toggle — лампа на D2 переключится, а в журнале появится ответ в pinport/demo/led/state. Нажмите кнопку на схеме и найдите pinport/demo/button с текстом pressed 1.
Открыть лампу и кнопкуRetain и последняя воля: чтобы панель знала правду
Представим, что телефон открыл панель управления через час после того, как лампу включили. Он подписался на pinport/demo/led/state, но сообщение on было опубликовано час назад, и брокер его уже никому не отдаст. Панель будет показывать «неизвестно», пока лампа не переключится снова.
Флаг retain решает это. Брокер хранит последнее сохранённое сообщение каждой темы и отдаёт его каждому новому подписчику сразу при подписке. Поэтому состояние лампы публикуется с третьим аргументом true:
mqtt.publish(T_STATE, on ? "on" : "off", true);
А вот команды с retain публиковать нельзя. Сохранённое toggle в pinport/demo/led будет приходить лампе при каждом переподключении, и она станет сама переключаться после любого сбоя WiFi. Правило такое: retain — для состояния, без retain — для событий и команд. Нажатие кнопки pressed 1 в примере публикуется без retain, потому что это событие, а не состояние.
Последняя воля (Last Will) — сообщение, которое клиент оставляет брокеру при подключении. Если клиент пропадёт без прощания — отключилось питание, упал WiFi, — брокер сам опубликует его волю. В примере это offline в pinport/demo/status с retain. Сразу после подключения плата публикует туда же online с retain. В итоге в теме статуса всегда правда: либо плата подтвердила, что жива, либо брокер сообщил, что она пропала.
Как брокер узнаёт, что клиент пропал? Клиент обещает при подключении интервал keep-alive, у PubSubClient по умолчанию 15 секунд, и в паузах шлёт служебный пинг. Если от клиента ничего не приходит дольше полутора интервалов, то есть 22,5 секунды, брокер закрывает соединение и публикует волю.
Термостат: JSON, уставка и гистерезис
Климат комнаты — пример посложнее. Датчик DHT22 на D15 каждые пять секунд публикует температуру и влажность в pinport/room/climate. Плата подписана на pinport/room/setpoint и pinport/room/interval, так что уставку и период опроса можно менять сообщениями. Обогреватель изображает светодиод на D2.
Обработчик не доверяет входящим данным. Байты копируются в буфер фиксированного размера и завершаются нулём: PubSubClient передаёт полезную нагрузку без завершающего нуля, и atof на сыром указателе прочитал бы лишнее. Уставка вне диапазона 5…35 не принимается, и в порт уходит объяснение.
Главное в термостате — гистерезис:
if (t < setpoint - HYST) setHeater(true);
else if (t > setpoint + HYST) setHeater(false);
При уставке 24 и HYST = 0.5 обогрев включается ниже 23,5 градуса и выключается выше 24,5. Между ними он сохраняет прежнее состояние. Без этой петли температура, колеблющаяся около 24,0 на десятые доли, переключала бы реле при каждом измерении. Состояние обогрева уходит в pinport/room/heater с retain, и только при изменении: setHeater выходит сразу, если состояние то же.
Сценарий примера проверяет это по шагам. При 23 градусах и уставке по умолчанию 22 обогрев выключен. Сообщение 24 в pinport/room/setpoint делает 23 градуса «холодом», и обогрев включается. Датчик нагревают до 25 — это выше 24,5, обогрев выключается. Уставка 99 отвергается с сообщением «вне 5..35».
Брокер в симуляторе и дома
В симуляторе PinPort брокер встроен в виртуальную сеть. Любое подключение WiFiClient на порт 1883 попадает в него, какой бы адрес ни был в скетче. Поэтому примеры написаны с broker.hivemq.com: в симуляторе они говорят со встроенным брокером, а на настоящей плате тот же код подключится к публичному брокеру HiveMQ. Поддерживаются MQTT 3.1 и 3.1.1, QoS от 0 до 2, маски, retain, последняя воля. Порт 8883 с TLS встроенный брокер не принимает и объясняет почему.
Под схемой у плат ESP32 есть вкладка «MQTT». В ней журнал подключений, подписок и публикаций, включая разрывы с причинами, список подписок платы — щелчок переносит тему в поле публикации, — сохранённые сообщения и форма публикации с retain и QoS. Если плата что-то опубликовала, пока вкладка закрыта, на её заголовке появляется точка.
Дома вместо встроенного брокера ставят свой, самый распространённый — Mosquitto. В скетче меняется одна строка: вместо broker.hivemq.com адрес компьютера с брокером в домашней сети, например 192.168.1.10. Публичный брокер удобен для первых опытов, но он общий на весь мир: любой может подписаться на # и прочитать ваши темы или опубликовать в них команду. Для настоящей квартиры нужен свой брокер, а если он доступен снаружи — логин, пароль и TLS.
Типичные ошибки
| Симптом | Причина |
|---|---|
| Плата подключается и сразу отваливается, по кругу | Два устройства с одним идентификатором клиента вытесняют друг друга |
| Сообщения приходят с большой задержкой или соединение рвётся | Долгий delay() в loop(), mqtt.loop() вызывается редко |
| Лампа сама переключается после сбоя связи | Команда опубликована с retain |
| Панель не знает состояния до первого изменения | Состояние публикуется без retain |
| Длинный JSON не доходит | Буфер PubSubClient по умолчанию 256 байт, нужен setBufferSize |
| После переподключения команды не приходят | Подписка сделана один раз в setup() |
Первая строка таблицы касается ночника: он подключается с постоянным идентификатором pinport-nightlight. Для одного ночника это нормально, но второй такой же в соседней комнате будет выбивать первого. Решение то же, что в примере с лампой, — добавить к идентификатору MAC-адрес.
Итог
MQTT сводит умный дом к одной договорённости: у каждого устройства есть темы, куда оно пишет, и темы, которые оно слушает. Брокер в домашней сети связывает их без облака. Состояние публикуют с retain, команды — без, последняя воля сообщает о пропавшем устройстве, а mqtt.loop() вызывают часто и без долгих задержек. В симуляторе все эти правила видны во вкладке «MQTT»: кто подключился, на что подписан, что сохранено и что пришло в ответ.
Попробуйте в симуляторе
MQTT: климат комнаты и обогрев
Снимите галочку «запускать со сценарием», запустите и опубликуйте во вкладке «MQTT» в pinport/room/setpoint число 26. Обогрев включится, если DHT22 показывает меньше 25,5 градуса; поднимите температуру датчика в свойствах до 27 и дождитесь off в pinport/room/heater.
Термостат по MQTTУмный ночник по MQTT: цвет из телефона, яркость по фоторезистору
Опубликуйте в pinport/night/color текст #0000ff — лента посинеет, а в pinport/night/state придёт новый цвет. Поставьте фоторезистору 5000 люкс и проверьте, что ночник погас и сообщил brightness 0. Затем пришлите red вместо #RRGGBB и найдите ответ в мониторе порта.
Ночник по MQTTWiFi: подключение с переподключением
Посмотрите, как устроено переподключение с таймаутом 15 секунд, и перенесите ту же проверку WiFi.status() в loop() примера с лампой перед проверкой mqtt.connected().
Переподключение WiFi