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 и найдите ответ в мониторе порта.

Ночник по MQTT

WiFi: подключение с переподключением

Посмотрите, как устроено переподключение с таймаутом 15 секунд, и перенесите ту же проверку WiFi.status() в loop() примера с лампой перед проверкой mqtt.connected().

Переподключение WiFi

← Все статьи