Роутер у меня дома перезагружается сам: провайдер ставит обновление каждую ночь около четырёх. Гаражный узел на ESP32 жил со скетчем, где в главном цикле стоял delay(5000) после каждой публикации. Температуру он показывал исправно, а кнопка света в приложении срабатывала через раз. Разбор вёл в тупик, пока я не посчитал окно глазами: пять секунд после публикации callback — функция, которой брокер приносит команды, — не работал вовсе. Нажатие, попавшее в паузу, ждало до пяти секунд, а при неудачном тайминге умирало совсем: следующий пинг не успевал уйти в окно keepalive, и брокер резал соединение, которое скетч считал живым. Лаг до пяти секунд глаз замечает сам, и против него бессильны любые красивые анимации.
Совет с форумов — обернуть reconnect() — лечил следствие. Внутри while с delay(3000) плата стояла колом: датчик двери в гараже молчал, пока связь налаживалась, а связь налаживалась по кругу, если брокер лежал долго. Три темы на arduino.cc, которые я тогда перечитал, крутятся вокруг одной мысли: подключение — не пауза, а задача со своим состоянием.
В этом уроке переписываем восстановление связи как машину состояний: ONLINE и WAIT, попытка — один вызов connect, пауза между попытками растёт экспоненциально — 500, 1000, 2000, 4000, 8000 миллисекунд, вручную, без библиотек. Каждого перехода машина печатает строку в Serial: после ночи в мониторе читается вся история связи, как в чёрном ящике.
Машина состояний: ONLINE, WAIT и два перехода
Название звучит академично, внутри же — один вопрос: где я сейчас и что делать дальше. ONLINE: соединение живо, крутится client.loop() и публикация по таймеру. WAIT: брокера нет, я жду свою минуту — метку nextTry. Ещё два события, попытка connect и отвал, — не состояния, а переходы, и я печатаю их отдельными строками: по меткам [ONLINE] и [WAIT] в журнале видно, где машина провела ночь. Механика millis здесь та же, что в уроке про millis вместо delay и кооперативном цикле: вместо паузы — отметка времени и проверка.
delay(5000) плох не сам по себе, а тем, что прячет. За пять секунд паузы входящая команда не разбирается, кнопки не опрашиваются, индикация замирает. И главное — keepalive: PubSubClient показывает признаки жизни внутри client.loop(), окно по умолчанию 15 секунд, мы считали его в уроке про keepalive и PingReq. Два delay по пять секунд плюс работа кода — и пинг балансирует на грани; брокер не видит пинга, закрывает соединение, а скетч продолжает публиковать в никуда и числиться мёртвым.
Экспоненциальная пауза собирается в две строки: waitMs = waitMs * 2 и потолок через if. Стартовая — 500 миллисекунд, короткая нарочно: моргающий Wi-Fi чаще всего возвращается за две-пять секунд, и первую попытку надо успеть почти сразу, чтобы дашборд ожил без ощутимой паузы. Каждая неудача удваивает паузу — 500, 1000, 2000, 4000, 8000 — дальше потолок восемь секунд. Успех сбрасывает значение на 500: следующая беда снова начнётся с быстрой реакции. Потолок нужен не плате, а брокеру: после его рестарта сотни клиентов с одинаковыми таймерами ломанутся одним залпом, и экспонента с потолком этот шторм размазывает во времени.
Почему состояний всего два, а не пять, — честный вопрос. Этаж Wi-Fi живёт ниже: у сети свой статус, свой автореконнект и своя логика, и смешивать их в одной машине я перестал после того, как два вечера чинил переход, которого в природе не существовало. Машина соединения отвечает ровно за одно — вижу ли я брокера; розетка, роутер и DHCP для неё погода. Такая узость не беда, а достоинство: два состояния можно нарисовать на бумаге, и нарисованное сходится с кодом строка в строку.
#include <WiFi.h>#include <PubSubClient.h> const char* WIFI_SSID = "HomeNet";const char* WIFI_PASS = "supernet123";const char* MQTT_SERVER = "192.168.1.50"; WiFiClient net;PubSubClient client(net); const int ST_WAIT = 0; // сеть лежит: ждём свою минутуconst int ST_ONLINE = 1; // брокер отвечаетint state = ST_WAIT; unsigned long waitMs = 500; // стартовая пауза, мсconst unsigned long MAX_WAIT = 8000; // потолок экспонентыunsigned long nextTry = 0;unsigned long lastPub = 0; void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); client.setServer(MQTT_SERVER, 1883); nextTry = millis() + 1000; // первая попытка через секунду после старта} void loop() { unsigned long now = millis(); if (state == ST_WAIT && now >= nextTry && WiFi.status() == WL_CONNECTED) { if (client.connect("esp32-garage")) { state = ST_ONLINE; waitMs = 500; // успех: экспонента на старт client.subscribe("garage/cmd"); // чистая сессия забыла подписки Serial.println("[ONLINE] соединение поднято, пауза сброшена"); } else { waitMs = waitMs * 2; // экспонента вручную: 500, 1000, 2000, 4000, 8000 if (waitMs > MAX_WAIT) { waitMs = MAX_WAIT; } nextTry = now + waitMs; Serial.print("[WAIT] попытка не прошла, следующая через мс "); Serial.println(waitMs); } } if (state == ST_ONLINE) { if (!client.connected()) { state = ST_WAIT; // переход: брокер отвалился waitMs = 500; nextTry = now + 200; // первую попытку не тянем Serial.println("[WAIT] связь пропала"); } else { client.loop(); if (now - lastPub >= 20000) { // телеметрия живёт под if (state == ST_ONLINE) lastPub = now; client.publish("garage/temp", "23.5"); } } } // сюда ставьте датчики и кнопки: машина их никогда не останавливает}Порядок этажей тот же, что в уроке про публикацию с ESP32: сначала Wi-Fi, потом брокер. В условии попытки стоит проверка WiFi.status() == WL_CONNECTED — пока сети нет, connect бессмыслен: у Wi-Fi свой автореконнект, и это отдельная история на этаж ниже. Сама попытка — ровно один вызов client.connect(): библиотека ждёт ответа до MQTT_SOCKET_TIMEOUT, по умолчанию это 15 секунд, поэтому никакого while внутри — иначе loop снова превращается в чёрную дыру, только прилично выглядящую.
Печати переходов — моя любимая часть машины. После ночи с шатающейся связью в мониторе читается всё: когда отвалилась, сколько попыток съела, где сбросилась на 500. Полчаса строк [WAIT] подряд — мёртвый брокер, а не флап Wi-Fi, и это видно с первого взгляда, если держать печати в формате журнала с уровнями. Отдельный бонус — поиск виновника по ритму: строка [WAIT] каждые восемь секунд означает, что брокер жив, а вот роутер между вами — нет.
Библиотечные менеджеры соединений я для первых проектов не люблю и не скрываю: в двадцати строках машины каждое состояние видно глазами, отладка — Serial, а не отладчик. Граница применимости честная: когда в проекте заводится несколько задач с дедлайнами, менеджер с его автоматикой экономит время; до этого момента ручная машина прозрачнее. Копилку импульсов и жизнь датчиков во время отвала рядом разбирали в уроке про reconnect без блокировки — там же потолок backoff для батарейных узлов.
Считать время возврата удобно по паузам. Лучший случай: связь моргнула на секунду — первая попытка через 200 миллисекунд подключает сразу, и пользователь ничего не замечает. Средний: роутер перезагружается минуту — машина успевает сделать четыре-пять попыток и подключается на первой же после возврата сети. Худший: брокер лежит час — пауза стоит на потолке 8000 миллисекунд, попыток за час выходит около четырёхсот пятидесяти, и брокер этого почти не чувствует. Три сценария, три цены — и все приемлемые; именно так я проверяю параметры экспоненты, прежде чем выкатывать их на прод.
Стратегии восстановления: таблица честных цен
| Стратегия | Что видит пользователь | Что видит брокер | Когда я её ставлю |
|---|---|---|---|
| delay(5000) в главном цикле | команды доезжают с лагом до 5 с, кнопки слепнут в окне паузы | пинг поджимается: окно keepalive 15 с на грани | нигде, кроме мигалки без обратной связи |
| while + delay(3000) в reconnect() | датчики и кнопки стоят колом до возврата связи | шторм попыток, пока связь мёртвая | узлы без кнопок, счётчиков и сирен |
| millis-машина с экспонентой 500-8000 мс | всё живёт, возврат виден по меткам в мониторе | попытки размазаны во времени, потолок держит шторм | мой выбор для всего, что щёлкает и считает |
| Готовый менеджер соединений | зависит от библиотеки | нормально | проекты, где уже есть планировщик задач |
Машина из двадцати строк ставится в любой скетч раздела: состояние, метка, две ветки. Датчики и кнопки живут вне if (state == ST_ONLINE) — сетевые события их не останавливают, и это главный признак того, что машина собрана правильно: если хотя бы одна лампочка ушла под сетевое условие, блокировка вернулась на чужих правах.
И последнее — про мигалку статуса. Светодиод в симуляторе горит ровно в сети и моргает в паузе: это не украшение, а самый дешёвый способ узнать состояние узла из-за угла шкафа. Ровно горит — соединение живо, моргает — машина в WAIT и считает паузу, не горит — питания нет. Три состояния, ноль байтов трафика.
Следующая ступень — датчик с длинным циклом: DHT22, публикация раз в тридцать секунд и интервал, который меняется командой из брокера — без перепрошивки и снятия платы с балкона.
- Щёлкните тумблер в положение «сеть лежит» и держите: попытки пойдут с паузами 500, 1000, 2000, 4000, 8000 — сверьте каждую с кодом.
- Верните сеть и найдите в мониторе строку сброса: пауза снова 500, светодиод горит ровно. Ветка успеха в коде одна — она и есть сброс.
- Уменьшите MAX_WAIT до 2000 и вообразите двести таких узлов после рестарта брокера: посчитайте, сколько connect-залпов прилетит в первую секунду.
- Добавьте в печать перехода значение millis() и сравните интервалы между попытками с waitMs: экспонента обязана сойтись с монитором.
- Обрыв связи — машина из двух состояний: ONLINE с client.loop() и WAIT с меткой nextTry; переходы печатаются в Serial
- delay(5000) в главном цикле глушит callback на пять секунд и поджимает окно keepalive в 15 секунд
- Экспонента вручную: waitMs = waitMs * 2 с потолком 8000 мс; успех сбрасывает на стартовые 500
- Попытка — один client.connect(): библиотека может держать его до MQTT_SOCKET_TIMEOUT, 15 секунд
- client.subscribe() — только в ветке успеха: после reconnect брокер начинает чистую сессию
- 1. Сколько может держать loop один client.connect() к молчащему брокеру?
- 2. Почему стартовая пауза 500 мс, а не сразу потолок 8000?
- 3. Что плохого в delay(5000) в главном цикле MQTT-скетча?
- 4. Зачем экспоненциальной паузе потолок?
- 5. Где в машине состояний живёт client.subscribe()?
Частые вопросы по теме урока
- Сколько может держать loop один client.connect() к молчащему брокеру?
- PubSubClient ждёт ответа в пределах MQTT_SOCKET_TIMEOUT, по умолчанию 15 секунд. Поэтому в машине попытка одна за раз: while вокруг connect вернул бы чёрную дыру в loop. До 15 секунд — столько стоит MQTT_SOCKET_TIMEOUT в библиотеке
- Почему стартовая пауза 500 мс, а не сразу потолок 8000?
- Короткий моргнувший Wi-Fi возвращается за две-пять секунд. Потолок существует против шторма после рестарта брокера, а не против первого флапа. Флап Wi-Fi чаще всего короткий: быстрая первая попытка возвращает дашборд почти сразу
- Что плохого в delay(5000) в главном цикле MQTT-скетча?
- Callback разбирается внутри client.loop(), который delay блокирует. Два delay по пять секунд плюс работа кода — и пинг не успевает в окно: брокер режет соединение. Входящие команды ждут до пяти секунд, а окно keepalive в 15 с поджимается
- Зачем экспоненциальной паузе потолок?
- Без потолка пауза уходит в минуты, а брокер всё равно получает залп при рестарте. Экспонента с потолком растягивает попытки и держит возврат предсказуемым. Чтобы после рестарта брокера сотни клиентов не били одним залпом: попытки размазываются по времени
- Где в машине состояний живёт client.subscribe()?
- После каждого reconnect брокер начинает чистую сессию, и подписку ставят заново — ровно в одном месте, в ветке успеха. В ветке успешного connect: чистая сессия забыла старые подписки