Термометр на балконе я повесил в ноябре: ESP32, DHT22 и скетч из первого урока MQTT, допиленный на коленке. Работало отлично — ровно пятнадцать секунд. Потом данные в брокере замирали, а плата сидела молча, как в отпуске. Перезагрузка помогала — снова на пятнадцать секунд.
Два дня я боролся с призраками: перепрошил плату, отодвинул ESP32 от роутера, поменял блок питания, чуть не заказал новый роутер. Тупик был полный, потому что код выглядел правильно: подключился, публикует — всё по документации. Ловушка сидела в одной функции, которую я вызывал слишком редко.
В этом уроке собираем взрослый скетч публикации: Wi-Fi с ожиданием, reconnect-логика, client.loop() в millis-цикле и подписка с callback — концептуально, чтобы понимать, куда вписывается команда «включи свет» с сервера. После него ваши данные перестанут обрываться на полуслове.
Каркас: сначала Wi-Fi, потом брокер
ESP32 выходит в сеть через WiFi.begin(имя, пароль), и дальше — первое правило: не трогать брокера, пока WiFi.status() не вернёт WL_CONNECTED. Подключение занимает от трёх до восьми секунд в зависимости от роутера; пока его нет, любые попытки connect к MQTT обречены. Я печатаю точку каждые полсекунды: и видно, что скетч жив, и ожидание не выглядит зависанием.
Второй шаг — client.setServer(адрес, 1883) и client.connect(clientId). Про уникальность имени помните из прошлого урока: два ESP32 с одним clientId будут бесконечно выкидывать друг друга, а выглядит это как мистика — соединение живёт секунду и рвётся. Я дописываю к имени место установки: esp32-balcony, esp32-greenhouse — через полгода в логах брокера всё ещё понятно, кто есть кто.
Датчик в этой паре — DHT22 на любом цифровом пине, у нас четвёртом: dht.readTemperature() отдаёт градусы с плавающей точкой. Если датчик не ответил, а DHT22 иногда молчит, публикацию пропускаем: лучше пропасть одному отсчёту, чем отправить в график мусор.
Про Serial на ESP32 две мелочи, которые сбивают новичков. Скорость ставлю 115200: плата быстрая, и на 9600 отладочный поток заметно тормозит loop. А в первые секунды после подачи питания в мониторе мелькают левые символы — это загрузчик ESP32 говорит на своей скорости, до Serial.begin() скетч ещё не дошёл. Не пугайтесь и не перепрошивайте плату из-за этого.
#include <WiFi.h>#include <PubSubClient.h>#include <DHT.h> const char* WIFI_SSID = "HomeNet";const char* WIFI_PASS = "supernet123";const char* MQTT_SERVER = "192.168.1.50"; const int DHT_PIN = 4;DHT dht(DHT_PIN, DHT22); WiFiClient net;PubSubClient client(net); unsigned long lastPublish = 0;const unsigned long PUBLISH_MS = 10000; // раз в 10 секунд void reconnect(); void setup() { Serial.begin(115200); dht.begin(); WiFi.begin(WIFI_SSID, WIFI_PASS); Serial.print("Подключаюсь к Wi-Fi"); while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); } Serial.println(" готово"); Serial.print("IP: "); Serial.println(WiFi.localIP()); client.setServer(MQTT_SERVER, 1883);} void loop() { if (!client.connected()) { reconnect(); // сеть есть, брокера нет — чиним } client.loop(); // обслуживает соединение и подписки unsigned long now = millis(); if (now - lastPublish >= PUBLISH_MS) { lastPublish = now; float t = dht.readTemperature(); if (isnan(t)) { // датчик иногда молчит — это нормально Serial.println("Датчик не ответил, пропускаю отсчёт"); return; } String payload = String(t, 1); client.publish("home/temp", payload.c_str()); Serial.print("home/temp = "); Serial.println(payload); }} void reconnect() { while (!client.connected()) { Serial.print("Подключаюсь к брокеру... "); if (client.connect("esp32-balcony")) { Serial.println("готово"); client.subscribe("home/led"); // подписка живёт до разрыва } else { Serial.println("не вышло, жду 3 секунды"); delay(3000); } }}Порядок в setup() — не вкусовщина: Wi-Fi до брокера, setServer до connect. Заметьте, где живёт delay(3000) — внутри reconnect(). Пауза здесь осознанная: раз брокера нет, быстрее не станет, а заваливать его попытками каждую сотню миллисекунд — плохой тон; я видел, как нетерпеливый клиент уводил брокер на слабенькой Raspberry Pi в отказ. В остальном loop() крутится без единого delay.
Публикация сидит на millis-счётчике: раз в PUBLISH_MS — 10 секунд. Число с условием: для комнатной температуры десяти секунд достаточно, если вам нужен график за день; для контроля инкубатора я сокращаю до двух секунд, потому что там важна скорость реакции автоматики. Запись now - lastPublish >= PUBLISH_MS вместо now > lastPublish + PUBLISH_MS — защита от переполнения: millis() через 49 дней обнуляется, и простая форма сломала бы тайминг ровно один раз.
String(t, 1) превращает 23.45678 в аккуратное 23.5 — один знак после запятой. В publish строка уходит через .c_str(): библиотека ждёт C-строку, а не объект String. Эти два штриха делают сообщения читаемыми в любом дашборде.
Почему reconnect() написан через while, а не через if с флагом? Потому что подключение либо есть, либо его нет и делать больше нечего: датчик подождёт пару секунд, а публиковать без соединения бесполезно. Блокирующая версия честна о том, что в этот момент проект стоит. Если у вас в loop() есть что-то срочное — мигалка безопасности, счётчик импульсов воды — тогда подключение переводят в неблокирующее состояние с millis-таймером, но это уже следующий уровень.
Тупик: ровно 15 секунд тишины
Теперь тайна из вступления. Протокол MQTT требует, чтобы клиент показывал признаки жизни, — это называется keepalive. PubSubClient по умолчанию держит окно в 15 секунд: если брокер столько не слышал от клиента ни слова, он считает его мёртвым и закрывает соединение. Признаки жизни клиент показывает внутри client.loop() — там летит пинг и разбираются входящие пакеты.
Мой первый скетч выглядел так: опубликовал, delay(10000), опубликовал... delay плюс время на остальной код давал цикл длиннее пятнадцати секунд, keepalive не успевал, и брокер отрезал соединение. Перезагрузка платы подключала заново — отсюда ровные пятнадцать секунд счастья. Новый роутер бы не помог, а вот одна строка client.loop() перед каждой паузой помогла бы сразу. С тех пор я держу client.loop() в начале loop() и не ставлю в главном цикле delay дольше пары секунд — только в reconnect, где ждать всё равно больше нечего.
Кстати, окно keepalive настраивается: у client.connect() в расширенной форме среди параметров есть keepalive в секундах. Увеличивать его стоит с оглядкой: большое окно означает, что обрыв связи вы заметите нескоро, а last will сработает с опозданием. Дефолтные 15 секунд — разумный компромисс для домашней сети, и менять их без причины я смысла не вижу.
Подписка и callback: брокер умеет и писать нам
До сих пор мы только публиковали. Обратное направление устроено так: в reconnect() после успешного connect вызывается client.subscribe("home/led"), а в setup() — client.setCallback(callback). После этого брокер сам дёргает вашу функцию, когда в подписанный топик прилетает сообщение: подписка живёт на стороне брокера и переживает многократные публикации скетча.
Callback — обычная функция с тремя параметрами: имя топика, массив байтов и длина. Ключевой момент, на котором спотыкаются: payload — это не строка, а байты, и сравнивать его надо, собрав в String. Мини-скелет, который я вставляю заготовкой в каждый новый проект:
Топиков у подписки может быть несколько: client.subscribe() для «home/led» и для «home/pump» спокойно уживаются, а различать входящие в callback нужно по имени топика — сравнением строк. Это главный переключатель обратного потока: один callback, а внутри — if по topic. Так один ESP32 обслуживает и свет, и насос, не путаясь в том, чья команда прилетела.
И маленькое правило безопасности: payload с брокера — данные из внешнего мира, как ввод с клавиатуры. Проверяйте его на то, что ожидали: если ждёте «1» или «0», всё остальное игнорируйте. Один раз я отладил скетч по Serial, забыл про проверку, а потом полдня ловил, почему свет гаснет от случайного сообщения в тестовом топике коллеги.
void callback(char* topic, byte* payload, unsigned int length) { String message = ""; for (unsigned int i = 0; i < length; i++) { message += (char)payload[i]; // собираем байты в строку } Serial.print(topic); Serial.print(" -> "); Serial.println(message); if (message == "1") { digitalWrite(2, HIGH); // команда «включи свет» } else { digitalWrite(2, LOW); }} // в setup(): client.setCallback(callback);// в reconnect(): client.subscribe("home/led");- Сократите интервал публикации с 5000 до 1000 мс и посмотрите на плотность потока. А теперь аргумент против: каждая публикация — пакет в сети и запись у брокера. Оставьте тот интервал, который действительно нужен вашей температуре.
- Добавьте второй счётчик millis и публикацию home/humidity раз в 15 секунд: у одного клиента может быть сколько угодно топиков с разными периодами.
- Поменяйте clientId в connect() на dacha-greenhouse и запустите: в логе появится новое имя клиента. Брокер различает абонентов по нему — помните про конфликт одинаковых имён из первого урока.
- Напишите на бумаге маршрут сообщения: DHT22, переменная, publish, брокер, дашборд. Отметьте места, где сообщение может потеряться при QoS 0, и решите, где бы вы поставили QoS 1. И проверьте себя: дубликат при QoS 1 — это когда реле щёлкнет дважды; продумайте, безвредно ли это для вашего насоса.
- Порядок старта: WiFi.begin, дождаться WL_CONNECTED, setServer, connect, subscribe
- clientId уникален для каждого устройства: esp32-balcony, esp32-greenhouse
- reconnect() вызывается при !client.connected() и ждёт 2–3 секунды между попытками
- client.loop() — в начале каждого loop(): keepalive 15 секунд и входящие подписки
- Публикация по millis-интервалу; форма now - lastPublish >= interval переживает переполнение millis
- payload — байты: String(...).c_str() для publish; буфер пакета по умолчанию 256 байт
- Проверяйте payload в callback: это данные из внешнего мира, доверять им слепо нельзя
- 1. Зачем в setup() цикл while (WiFi.status() != WL_CONNECTED)?
- 2. Скетч с delay(10000) в главном цикле теряет связь каждые 15 секунд. Почему?
- 3. Что случится при двух клиентах с одинаковым clientId?
- 4. Как передать String в client.publish()?
- 5. Почему сравнение now - lastPublish >= interval безопаснее для millis?
- 6. Где уместна пауза delay(3000)?
Балконный термометр с этим скетчем живёт без перезагрузок месяцами: Wi-Fi моргнул — reconnect поднял, брокер перезапустился — reconnect догнал. Дальше по разделу — retained-сообщения, чтобы дашборд показывал температуру сразу после открытия, last will, чтобы дом узнавал об упавшей плате, и шифрованный порт 8883, когда брокер выходит наружу.
Проверьте себя на будущее: сможете ли вы, не глядя в код, назвать три причины, по которым соединение с брокером может рваться? Wi-Fi, перезагрузка брокера, просроченный keepalive — если да, значит урок лёг куда надо. Ваш умный дом уже разговаривает — осталось договориться, о чём именно.
Частые вопросы по теме урока
- Зачем в setup() цикл while (WiFi.status() != WL_CONNECTED)?
- Пока Wi-Fi не поднялся, любые попытки connect к брокеру провалятся; ждём WL_CONNECTED и только потом работаем с MQTT. Чтобы не стартовать MQTT без сети: connect к брокеру заранее обречён
- Скетч с delay(10000) в главном цикле теряет связь каждые 15 секунд. Почему?
- PubSubClient шлёт пинг и разбирает пакеты только внутри client.loop(); длинная пауза превышает окно keepalive, и брокер закрывает соединение. Keepalive 15 секунд: пинг уходит внутри client.loop(), который delay блокирует
- Что случится при двух клиентах с одинаковым clientId?
- Имя клиента — его паспорт: новое подключение с тем же именем рвёт старое, и соединения бесконечно сменяют друг друга. Брокер будет выкидывать то одного, то другого
- Как передать String в client.publish()?
- PubSubClient ждёт C-строку; метод c_str() отдаёт внутренний буфер String, и publish его принимает. payload.c_str()
- Почему сравнение now - lastPublish >= interval безопаснее для millis?
- Беззнаковое вычитание даёт верную разницу даже при переходе millis через ноль; сумма в простом сравнении ломается. Оно корректно переживает переполнение millis через 49 дней
- Где уместна пауза delay(3000)?
- В reconnect() ждать всё равно нечего, и пауза защищает брокер от шторма попыток; в рабочем loop() она блокирует client.loop(). Внутри reconnect() между неудачными попытками