Тепличный узел жил по графику, как студент по расписанию пар: три-четыре минуты данных — пауза — снова данные. Wi-Fi был ни при чём: пинг до роутера не рвал, скорость держал, сигнал палками не мигал. Клиент же в мониторе писал одно и то же: разрыв, reconnect, state=-3 — и так по кругу каждые четыре минуты, с хирургической точностью.
Я успел обвинить роутер, прошивку и саму библиотеку, прежде чем нашёл виновника: за неделю до того я добавил в loop() синхронизацию времени по NTP с ожиданием ответа до двадцати секунд. Блокировка дольше окна keepalive — и брокер закономерно отрезает молчуна. Диагноз занял бы минуту, если бы я с самого начала измерял, сколько длится один проход главного цикла.
В этом уроке вскрываем пульс MQTT: что за пакеты PINGREQ и PINGRESP, кто их шлёт и когда, зачем нужно окно keepalive и как его настраивать через setKeepAlive. Затем — вторая часть головной боли, которую не чинит никакой setKeepAlive: NAT-роутеры, выкидывающие «мёртвые» соединения из своих таблиц. И в финале — диагностический скетч, который сам говорит, стоял ли ваш loop.
Пинг: PINGREQ, PINGRESP и окно терпения
TCP-соединение молча лежит, пока по нему не идут данные: сеть не отличает спящего клиента от умершего. Протокол MQTT добавляет свой пульс. В пакете CONNECT клиент объявляет окно keepalive в секундах — обещание, что в течение этого времени он проявит любую активность. Если трафика нет, клиент сам шлёт пустой пакет PINGREQ, брокер отвечает PINGRESP — и обе стороны довольны: связь жива.
Терпение у брокера нормированное: по спецификации он считает клиента мёртвым после полутора окон тишины. При окне в 15 секунд — до 22–23 секунд молчания, при 60 — до полутора минут. Отправку пинга при этом никто не вызывает отдельно: PINGREQ уходит изнутри client.loop(), той самой функции, которая обслуживает соединение. Не зовёте loop — пинга нет — и окно сгорает, даже если скетч выглядит занятым делом. Мы уже наступали на это в уроке про подключение ESP32, где цикл с delay(10000) умирал ровно на пятнадцатой секунде.
Окно задаёт клиент: у PubSubClient по умолчанию 15 секунд, меняется вызовом client.setKeepAlive(секунды) — до connect, раз и навсегда в сессии. Код -3 из таблицы состояний — MQTT_CONNECTION_LOST — это как раз тот случай, когда связь умерла посреди работы, и чаще всего по вине именно окна.
#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); unsigned long lastLoop = 0; // когда начался прошлый проход loop()unsigned long worstGap = 0; // самая длинная пауза за окно отчётаunsigned long lastReport = 0; bool connectMqtt() { Serial.print("connect... state="); Serial.println(client.state()); return client.connect("greenhouse-thermo");} void setup() { Serial.begin(115200); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer("192.168.1.50", 1883); client.setKeepAlive(30); // окно 30 с: запас под тяжелые операции while (!connectMqtt()) { delay(3000); } lastLoop = millis();} void loop() { unsigned long now = millis(); unsigned long gap = now - lastLoop; // сколько прошло с прошлого прохода lastLoop = now; if (gap > worstGap) { worstGap = gap; } if (!client.connected()) { while (!connectMqtt()) { delay(3000); } } client.loop(); if (now - lastReport >= 10000) { lastReport = now; Serial.print("t="); Serial.print(now / 1000); Serial.print("s gap_max="); Serial.print(worstGap); if (worstGap > 15000) { Serial.println(" ms RISKA: loop stoi, proveryaj delay"); } else { Serial.println(" ms ok"); } worstGap = 0; }}Счётчик gap из скетча — измеритель честности: он показывает самую длинную паузу между проходами loop за последние десять секунд. Пока gap_max меньше половины окна — пинг успевает уйти с запасом. Полсекунды на Serial-печать, секунда на чтение датчика — это нормальные гребни, за которыми окно не сгорает. Мой ориентир при выборе окна: ставь в три-четыре раза больше самой долгой плановой блокировки. Есть тяжёлый HTTP-запрос на 8 секунд — окно 30 секунд; читаете DS18B20 с конверсией 750 мс — хватит и 15. Выше 60–120 секунд окно я не поднимаю никогда: чем оно больше, тем позже брокер объявит клиента мёртвым и тем позже сработает завещание из урока про LWT — до полутора минут вранья online в дашборде.
Тупик, на котором я сидел два вечера: NTP-синхронизация из вступления. Запрос времени висел до двадцати секунд, окно было дефолтным — пятнадцать, и каждые пять минут связь рвалась с воем. Вторая версия той же беды — HTTP GET к медленному серверу: таймауты по 10–15 секунд съедали окно на границе. Лечение в обоих случаях одинаковое по духу: не делать долгих операций в теле loop, а выносить их в задачи планировщика на millis — тем самым цикл между операциями снова короткий, client.loop() дышит, а delay из главного цикла исчезает как класс.
С критерием не путать: пауза между публикациями — нормальная, хоть час. Опасна пауза между проходами loop, то есть между обслуживаниями соединения. Публикация раз в минуту при живом цикле — это пинг каждые 7–8 секунд внутри loop и ноль проблем; цикл, спящий минуту, — это мёртвый клиент, который ещё не знает о своей смерти.
Симптомы: кто рвёт связь на самом деле
| Симптом | Вероятный виновник | Первое действие |
|---|---|---|
| Отвал при паузах около 15–23 секунд тишины | блокирующий delay или while без client.loop() | убрать паузы из loop, вернуть gap_max в норму |
| Отвал раз в несколько минут, рядом HTTP-запросы | таймауты внешних вызовов съедают окно | setKeepAlive(30) плюс вынос запросов в задачи |
| Исходящие идут, входящие пропадают | NAT выкинул запись из таблицы | сократить окно, чтобы пинги ходили чаще |
| Раз в сутки, без закономерности | перезагрузка брокера, провайдер, DHCP сменил адрес | смотреть лог брокера и журнал платы |
Порядок диагностики у меня такой, после пары ночей по кругу. Первый шаг — client.state() сразу после разрыва: -3 говорит, что клиент увидел смерть соединения, и дальше вопрос только в причине. Второй — журнал брокера: в нём видно, кто закрыл соединение первым и с какой формулировкой; клиента, отсечённого по keepalive, там видно напрямую. Третий — gap_max из скетча: одна цифра, которая за десять секунд доказывает, стоял ли ваш цикл. Только когда все три показания чистые, я позволяю себе грешить на сеть и роутер — до этого подозреваемым номером один остаётся мой же код.
Всё это печатается в журнал платы по правилам из урока про уровни логов: разрыв — уровень error с кодом, отчёт gap_max — info. Пары имя=значение тут не прихоть: числа из журнала подхватываются тем же Serial Plotter, и разрыв видно как провал на графике. Через неделю такого журнала картина читается глазами: ровные ряды gap_max=20010 ms около отвала не оставляют NAT-у и провайдеру ни единого шанса стать подозреваемыми. Диагностика без цифр — это гадание, а гадание на железе дорого стоит.
Разрыв в журнале я печатаю одной строкой с тремя числами: время в секундах от старта, код state и текущий gap_max — например t=527s state=-3 gap=20010 ms. Штамп времени беру из millis()/1000: перезагрузку платы видно по нулевым секундам. Трёх-четырёх строк на разрыв хватает, чтобы через неделю листать журнал как протокол эксперимента, а не как берлогу. Без отметки времени разрывы из разных дней сливаются в кашу, и закономерность — каждые пять минут или раз в сутки — уже не видна.
Батарейным узлам — отдельный абзац. ESP32 в глубоком сне не шлёт пингов, поэтому связь для него — это короткая активная фаза: вышел из сна, подключился, опубликовал показания, забрал команды, попрощался через disconnect, уснул. Окно keepalive тут выбирают больше цикла сна — при сне десять минут ставят 1200 секунд, — иначе брокер объявит спящего мёртвым между пробуждениями, а завещание из урока про LWT выпишет offline прямо в дашборд. Постоянно подключённый клиент и спящий клиент живут по разным правилам — смешивать их в одном скетче не стоит.
И про чужие серверы: правила те же, окно другое. У облачных брокеров встречаются ограничения и снизу, и сверху: AWS IoT, например, ограничивает keepalive сверху 1200 секундами — двадцатью минутами, — и после полутора окон тоже объявляет клиента мёртвым. Свой mosquitto на малине терпеливее, но принцип один и тот же, поэтому навык диагностики переносится один в один: сначала журнал, потом сеть.
- Запустите симулятор и посчитайте строки PINGREQ за десять секунд: их четыре — ровно по пингу на каждые 2.5 секунды тишины при окне 5 секунд.
- Нажмите кнопку и посмотрите на строку tishina=: цифра будет около 6000 — больше окна, и брокер рвёт связь. Это модель состояния -3 из таблицы кодов.
- Поменяйте KA на 8000 и HALF на 4000, а delay оставьте 6000: теперь тишина меньше окна, и связь выживает. Тот же приём — setKeepAlive(30) при тяжёлых операциях на реальной плате.
- В настоящем скетче урока добавьте печать gap в журнал при каждом значении больше половины окна — и вы получите будильник за минуты до разрыва, а не после.
- Keepalive — обещание активности в окне: нет трафика — клиент сам шлёт PINGREQ изнутри client.loop(), брокер отвечает PINGRESP
- Брокер терпит полтора окна тишины: дефолтные 15 секунд PubSubClient — это отсечка на 22–23 секунде молчания
- setKeepAlive(секунды) ставят до connect; моё правило — окно в 3–4 раза больше самой долгой плановой блокировки, потолок 60–120 секунд
- Больше окно — позже завещание и слабее защита от NAT: пинги обновляют записи трансляций у роутеров, модемный NAT бывает жёстче 5–15 минут
- Диагностика отвала: state=-3, журнал брокера, счётчик gap_max — докажите, что loop не стоял, прежде чем винить сеть
- 1. Кто отправляет пакет PINGREQ?
- 2. Окно keepalive 15 секунд. После какой тишины брокер считает клиента мёртвым?
- 3. В loop() есть HTTP-запрос, который иногда висит 12 секунд. Какое окно keepalive вы поставите?
- 4. Публикация с платы идёт раз в час. Сколько нужно пинговать, чтобы соединение жило?
- 5. Плата за модемом 4G: исходящие публикации ходят, а входящие команды перестают приходить. Что происходит?
Частые вопросы по теме урока
- Кто отправляет пакет PINGREQ?
- PINGREQ шлёт клиент как признак жизни, PINGRESP — ответ брокера. Отправка происходит внутри client.loop(), поэтому заблокированный loop означает отсутствие пинга, и окно сгорает впустую. Клиент изнутри client.loop(), когда в окне нет другого трафика
- Окно keepalive 15 секунд. После какой тишины брокер считает клиента мёртвым?
- Спецификация отводит брокеру полтора окна терпения: 15 x 1.5 = 22.5 секунды. Публикация завещания происходит после этого срока. Около 22–23 секунд — полтора окна
- В loop() есть HTTP-запрос, который иногда висит 12 секунд. Какое окно keepalive вы поставите?
- Мой критерий: окно в 3–4 раза больше самой долгой плановой блокировки. Гигантское окно откладывает момент обнаружения смерти и замедляет завещание. Поставить 30–45 секунд и вынести запрос из главного цикла
- Публикация с платы идёт раз в час. Сколько нужно пинговать, чтобы соединение жило?
- Пауза между публикациями не вредит: пинг отправляется внутри client.loop() по окну keepalive, независимо от частоты publish, хоть раз в час. Пинг уйдёт сам: client.loop() крутится и шлёт PINGREQ при тишине в окне
- Плата за модемом 4G: исходящие публикации ходят, а входящие команды перестают приходить. Что происходит?
- Операторский NAT держит трансляцию минуты, не часы: тишина дольше — и входящие идут в пустоту. Пинги с коротким окном обновляют запись и держат канал. NAT-таблица модема выкинула запись по таймауту тишины