На дачном щитке жили вместе счётчик воды с импульсным выходом и датчик протечки с реле на кран. Связь с брокером держал скетч из урока про публикацию с ESP32 — с честным блокирующим reconnect(): while, внутри connect, между попытками delay(3000). Пока сеть была ровной, он меня устраивал. Ночью повис роутер, и к утру в журнале протечки обнаружились дырки: плате было не до кнопки — она сидела в reconnect, а сирена, слава богу, молчала не потому, что воды не было, а потому что скетч до неё в тот час просто не доходил.
Виновата не пауза, а связка. client.connect() к недоступному брокеру блокирует loop до того, как закроется TCP-попытка: на практике это секунды, у меня выходило от пяти до пятнадцати на круг. Плюс три секунды delay — и так по кольцу. Счётчик воды даёт импульс на литр, открытый кран льёт до двенадцати литров в минуту — импульс каждые пять секунд; один круг блокирующего reconnect пропускал два-три импульса, а ночь таких кругов — ведро воды мимо учёта. И вторая половина боли была не в счётчике: журнал протечки строится по моментам событий, а события, которые плата не успела опросить, в нём не появляются вовсе — это не опоздание, а дыра.
Лечение — перевести восстановление связи в фоновую задачу: две переменные состояния, millis-таймер попытки и backoff. Скетч вырос на десяток строк — датчики, кнопки и индикация при этом живут без остановки. Именно им reconnect без блокировки нужнее всего: сеть рано или поздно вернётся сама, а пропущенные импульсы и неопрошенная кнопка не вернутся никогда.
Состояние-машина: в сети и ждём
Сначала честность ради: блокирующий reconnect не порочен. Если в loop нет ничего срочного, while с паузой — простейшее рабочее решение, и я ставлю его во все узлы без исполнительных механизмов. Менять его стоит, когда в плате живёт что-то, что не может ждать: сирена, счётчик импульсов, мигалка статуса, опрос кнопки из кооперативного цикла.
Идея перевода на millis та же, что в уроке про millis вместо delay: вместо паузы — отметка времени и проверка. Появляется переменная состояния online и метка следующей попытки nextTry; loop каждый оборот решает, пора ли пытаться, а между попытками остаётся свободным целиком. Попытка раз в пять-десять секунд — компромисс, к которому я пришёл на своих узлах: чаще долбить незачем, реже — заметно запаздывание команд.
Стоит увидеть это в коде глазами: условие if (!online && now >= nextTry) срабатывает один раз за период — одну итерацию loop, — остальное время идут датчики и индикация. Внутри ветки — один вызов connect и разбор его результата; висячего while больше нет. client.loop() и публикация при этом живут под if (online): обслуживать мёртвое соединение бессмысленно, а датчики от этого условия не зависят — они вне сети.
Попытка — это один вызов connect, а не цикл: не подключился — вышли из ветки и вернулись в loop, датчики доработали до следующей метки. Именно здесь теряется главная боль: каждая попытка стоит в расписании, как публикация по таймеру, только с переменной паузой.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const unsigned long FIRST_WAIT = 5000; // первая пауза после отвалаconst unsigned long MAX_WAIT = 30000; // потолок backoffunsigned long waitMs = FIRST_WAIT;unsigned long nextTry = 0;bool online = false; void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); // ... ожидание WL_CONNECTED и setServer как обычно ... client.setServer(MQTT_SERVER, 1883); if (client.connect("dacha-water")) { online = true; client.subscribe("dacha/cmd"); } else { nextTry = millis() + waitMs; // не зависли: попытка по расписанию }} void loop() { unsigned long now = millis(); // всё срочное - вне сети и живёт всегда: сирена, кнопка, счётчик checkSiren(); countPulses(); if (!online && now >= nextTry) { if (client.connect("dacha-water")) { online = true; waitMs = FIRST_WAIT; // успех - пауза на старт client.subscribe("dacha/cmd"); // подписка заново: чистая сессия Serial.println("set, backoff sbroshen"); } else { waitMs = min(waitMs * 2, MAX_WAIT); // 5000 -> 10000 -> 20000 -> 30000 Serial.print("ne vishlo, sled cherez ms="); Serial.println(waitMs); nextTry = now + waitMs; } } if (online) { client.loop(); publishByTimer(); // millis-публикация из прошлого урока }}Backoff — удвоение паузы с потолком — нужен не плате, а брокеру. Представьте ночь, когда малина перезагрузилась: сотни клиентов, у каждого waitMs 5000, ломанутся reconnect почти одновременно — брокер, едва поднявшись, получит шторм подключений и может лечь второй раз. Пауза, растущая от пяти секунд до тридцати, растаскивает попытки по времени; у моих узлов потолок тридцать секунд, у батарейных — минута. Есть и обратная сторона медали: после рестарта брокера карточки в доме висят офлайн до первой успешной попытки, и чем короче стартовая пауза, тем быстрее дашборд оживает — отсюда и форма 5, 10, 20, 30, а не сразу тридцать.
Каждую попытку я пишу в журнал с уровнями: попытка — info, успех — отдельной строкой, а полчаса одних attempt подряд видны в логе с первого взгляда — это признак мёртвого брокера, а не короткого флапа Wi-Fi. Успех сбрасывает waitMs на стартовые пять секунд: следующая беда снова начнётся с быстрой реакции, а не с тридцатисекундного ожидания. И сразу после connect — subscribe: при чистой сессии брокер забыл старые подписки, мы разбирали это в уроке про callback; в этой машине подписка стоит ровно в одном месте — в ветке успеха.
Диагностика здесь та же, что в уроке про статусы подключения: после неудачной попытки печатаю state и код в журнал — минус два направит в конфиг брокера, четвёрка напомнит про пароль. И не путайте этажи: Wi-Fi со своим автореконнектом — отдельная история из урока про статус сети; пока WL_CONNECTED нет, попытки connect бессмысленны, и взрослый скетч ставит их за проверкой сети.
Жизнь во время отвала
| Что живёт на плате | Как себя ведёт | Мой приём |
|---|---|---|
| Индикация статуса | моргает всегда, различая режимы | LED: медленно в сети, часто в ожидании |
| Кнопки и импульсы | опрос не останавливается | счётчик в переменной, отправка после возврата |
| Показания датчиков | читаются по таймеру как обычно | температуру выбрасываю, счётчики коплю |
| Сирена и защита | не зависит от сети вообще | реагирует локально, MQTT только уведомляет |
| Журнал событий | пишет отметки по millis без сети | время с платы точнее, чем время получения |
Средняя строка таблицы — та, где выбор. Показания во время отвала я делю на два сорта по признаку воспроизводимости. Температуру и влажность выбрасываю: график не выигрывает от очереди устаревших отсчётов, а после возврата связи мгновенный шквал публикаций полезнее истории. Счётчики и события коплю: литр, который не посчитан, не вернётся; ограничение одно — под копилку отведите переменную с запасом и потолок, например сто импульсов, дальше пишите в журнал и перекидывайте на возврате связи. Дребезг кнопки в копилке гасится тем же приёмом, что и всегда: флаг «импульс учтён» держится до отпускания — два счёта на одно нажатие не пройдут.
Сирена протечки — верхняя строка таблицы и мой главный аргумент за всю эту переделку: реакция на воду не должна зависеть от роутера. Реле и зуммер подключены к датчику напрямую, опрос сирены стоит в каждом обороте loop — её реакции не нужны ни брокер, ни Wi-Fi, ни человек за дашбордом; MQTT только докладывает о происшествии, с лагом в секунды. Если бы кран закрывался по команде из брокера, ночь с повисшим роутером стоила бы мне ремонта пола. Локальная логика — локально, сеть — для уведомлений и команд, потерю которых пережить можно.
В симуляторе машина видна целиком: соединение рвётся по команде, индикация и счётчик живут, попытки идут по таймеру с растущей паузой, а после возврата копилка импульсов уезжает в брокера одним сообщением. Отметки событий при этом ставятся по millis самой платой — время с неё точнее, чем время получения на дашборде.
- Пошлите down и понажимайте кнопку: каждое нажатие печатает impuls с номером, а светодиод моргает чаще — жизнь во время отвала вживую. Затем пошлите up и найдите в мониторе сообщение с накопленным числом импульсов.
- Восстановите связь и посмотрите на паузу: в журнале попыток она снова 5000. Это сброс backoff после успеха — сверьте с кодом.
- Добавьте потолок копилки: если pulses дойдёт до 10, печатайте perepoln и дальше считайте, но не копите. Придумайте, что делать с десятим литрами, которых нет в отчёте.
- В коде урока для платы замените min(waitMs * 2, MAX_WAIT) на умножение без потолка и представьте сутки недоступного брокера: посчитайте, когда пауза перевалит за час. Потолок — не украшение.
- client.connect блокирует loop на секунды: переводить reconnect в millis-таймер нужно там, где живут сирены, кнопки и счётчики
- Два состояния и метка nextTry: попытка раз в 5–10 секунд, между попытками loop свободен целиком
- Backoff 5000 → 10000 → 20000 → 30000 растаскивает попытки сотен клиентов после рестарта брокера; успех сбрасывает паузу
- Subscribe — только в ветке успешного connect: чистая сессия забывает подписки при каждом переподключении
- Во время отвала датчики и индикация живут: температуру выбрасываю, счётчики коплю с потолком, защита не зависит от сети
- 1. Сколько примерно висит loop на одном client.connect() к недоступному брокеру?
- 2. Что делает backoff 5 → 10 → 30 секунд при неудачных попытках?
- 3. Почему subscribe стоит внутри ветки успешного reconnect?
- 4. Показания счётчика воды во время отвала — что делаю?
- 5. Температура комнаты во время отвала — что делаю?
Частые вопросы по теме урока
- Сколько примерно висит loop на одном client.connect() к недоступному брокеру?
- connect ждёт, пока сетевой стек закончит попытку: на практике от нескольких секунд до пятнадцати. В цикле while это время суммируется на каждый круг — именно так ночная пауза в счётчике воды выросла до ведра мимо учёта. Секунды — до закрытия TCP-попытки
- Что делает backoff 5 → 10 → 30 секунд при неудачных попытках?
- После падения брокера сотни клиентов ломанутся reconnect одновременно — шторм может уронить его второй раз. Растущая пауза с потолком растаскивает попытки; успех сбрасывает её на стартовые пять секунд. Растит паузу между попытками с потолком, растаскивая шторм клиентов после рестарта брокера
- Почему subscribe стоит внутри ветки успешного reconnect?
- PubSubClient просит чистую сессию, и каждое новое подключение начинает её с пустым списком подписок: лампа из callback-урока молчала именно поэтому. Подписка после каждого connect — обязательный шаг машины состояний, а не страховка на всякий случай. Чистая сессия: брокер забывает подписки при каждом новом connect
- Показания счётчика воды во время отвала — что делаю?
- Импульсы не воспроизводимы: литр мимо учёта не вернётся. Копилка с потолком и доотправка после reconnect — стандарт для счётных величин; потолок нужен, чтобы копилка не съела RAM за неделю молчания брокера. Коплю счётчик в переменной и доотправляю одним сообщением
- Температура комнаты во время отвала — что делаю?
- Температура воспроизводима и идёт потоком: после возврата связи свежий отсчёт полезнее очереди старых значений, а шквал публикаций после reconnect только мешает брокеру подняться. Копить я счётчики и события, а не телеметрию. Выбрасываю: графику не нужен backlog устаревших значений