Полуденная тишина: реле в теплице щёлкает от локальной логики, кнопки опрашиваются, а Serial молчит уже полминуты и мигание статуса замерло. На дашборде волю «offline» раздали минуту назад — брокер в порядке. Плата в это время стояла внутри client.connect() к умершему брокеру или внутри чтения сокета в client.loop(): вызов вернулся, loop пошёл дальше — и всё зажило. Никакой паники в коде не было; было ожидание, спрятанное внутри библиотеки.
Этажи здесь разные. Wi-Fi о своей смерти знает сразу: статус сети отваливается за секунды, и автомат восстановления сети подхватывает — это разобранный случай. Хуже, когда сеть жива, а соединение мертво: роутер умер тихо, кабель выдернули на дальнем конце, LTE-стык сглотнул пакет. TCP такой полусмерти не видит: ни FIN, ни RST в сторону платы не пришло, сокет полуоткрыт, и клиент честно ждёт ответа, пока транспорт ретранслирует — десятки секунд.
Здесь разбираю, где в PubSubClient спрятано ожидание, сколько оно длится по умолчанию и как его укоротить; за сам протокольный пинг и окно терпения брокера идите в урок про keepalive — я не пересказываю его, а спускаюсь этажом ниже, до сокета.
Пятнадцать секунд по умолчанию
В PubSubClient константа MQTT_SOCKET_TIMEOUT задаёт потолок ожидания для сетевых операций, и по умолчанию она равна пятнадцати секундам — это из официальной документации библиотеки. Тот же потолок накрывает и connect(): соединение с брокером — это TCP-рукопожатие плюс пакет CONNACK, и при мёртвом маршруте клиент стоит внутри connect весь таймаут. Метод setSocketTimeout(timeout) принимает число в секундах; я ставлю три-пять. Есть и второй путь — переопределить MQTT_SOCKET_TIMEOUT через define до include: значение выйдет одно на проект, тогда как метод позволяет каждому узлу свой таймаут, и я предпочитаю метод. Известная грабля из трекера библиотеки: connect блокирующий, и при недоступном брокере loop стоит весь таймаут — отсюда и замерший Serial в сцене выше.
Чем короче таймаут, тем быстрее ваша машина состояний связи заметит беду и переставит попытку по расписанию из урока про reconnect: пятнадцать секунд на попытку при попытке раз в пять превращают loop в сплошное стояние, четыре секунды оставляют датчикам воздух. Обратная сторона — паршивая связь: таймаут в одну секунду при задержках в сотни миллисекунд даст ложные разрывы на каждом чихе. Моё правило: таймаут в три-четыре раза меньше keepalive — при стандартных пятнадцати секундах пингования это два-пять секунд, и соединение успевает дышать.
Единицы — вечная грабля всей этой истории: setSocketTimeout принимает секунды, а setTimeout у Stream из ядра Arduino — миллисекунды, и у WiFiClient из ядра ESP8266 версии гуляют в обе стороны. Поставил не туда — получил либо «вечно ждёт», либо «мгновенно рвётся», и снаружи два случая неотличимы. Проверка одним замером: зазор loop вокруг client.loop() до и после вызова покажет фактическую длину ожидания. Не верьте имени функции — верьте миллисекундам в журнале; как этот зазор снимать, в скетче ниже.
Значение выбирается под маршрут, а не по вкусу. Домашний Wi-Fi с малиной в соседней комнате: четыре секунды — RTT в миллисекунды, запас огромный. LTE-узел с модемом: шесть-восемь секунд — задержки стыка в сотни миллисекунд, и короткое ожидание рвёт живые попытки. Стенд с мёртвым брокером на столе: две секунды, чтобы тесты не вязли. Смена инфраструктуры — повод перепроверить пару «keepalive минус таймаут»: переезд с домашнего брокера на облачный меняет RTT в разы, и прошивка, честно жившая год, начинает рвать соединения каждые десять минут — классический симптом таймаута, настроенного под старый маршрут.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const int PIN_LED = 12; // сердцебиение loopunsigned long lastBeat = 0; void setup() { Serial.begin(115200); pinMode(PIN_LED, OUTPUT); WiFi.begin(WIFI_SSID, WIFI_PASS); client.setServer(MQTT_SERVER, 1883); client.setSocketTimeout(4); // секунды! по умолчанию 15} void loop() { unsigned long beforeLoop = millis(); client.loop(); // здесь прячется чтение сокета unsigned long gap = millis() - beforeLoop; if (gap > 150) { // loop стоял дольше пары оборотов Serial.print("zazor loop = "); Serial.print(gap); Serial.println(" ms - chtenie soketa zhdyot"); } // сердцебиение: если моргание рвётся - loop где-то стоит if (millis() - lastBeat >= 400) { lastBeat = millis(); digitalWrite(PIN_LED, !digitalRead(PIN_LED)); } if (!client.connected()) { reconnectByTimer(); // машина состояний из урока про reconnect }}Половина соединения: кто кого заметит раньше
Анатомия полуоткрытого сокета достойна стола, потому что без неё диагноз превращается в гадание. Плата шлёт PINGREQ и ждёт PINGRESP; ответ не приходит, TCP ретранслирует, клиент сидит в блокирующем чтении до своего таймаута сокета. Брокер в это же время молчит со своей стороны и через полтора окна keepalive раздаёт волю — механика этого окна разобрана в уроке про завещание и retain. Итог честный и неприятный: дашборд узнаёт о смерти раньше самой платы, потому что у брокера окно короче, чем у клиента пятнадцать секунд на операцию плюс ретрансмиссии.
Третий персонаж этой сцены — NAT-таймаут роутера. Таблица трансляции держит запись о соединении пять-тридцать минут тишины в зависимости от прошивки, потом молча выкидывает её: соединение формально живо с обеих сторон, а пакету дальше роутера дороги нет. Пинг keepalive как раз греет NAT-запись — ещё одна причина не выключать его ради «экономии батарейки», экономия выходит боком. LTE-стык агрессивнее домашнего роутера: там дорога кончается за секунды тишины, и все мои батарейные узлы с модемом пингуют чаще домашних.
Короткий таймаут меняет поведение платы целиком, а не только скорость диагностики. Connect вернулся быстро — машина состояний разнесла попытки по времени и loop дышит. Publish при забитом канале перестаёт висеть: библиотека вернула ноль — значение уходит в копилку из первого урока кластера, а раздача из второго дотащит его позже. Каждый элемент кластера ловит свой участок аварии, и подвисший сокет — тот самый первый, без которого остальные просто не узнают, что пора работать.
Диагностика опирается на три строки журнала. Зазор loop из скетча выше показывает стояние в миллисекундах — сто пятьдесят миллисекунд это уже чтение сокета, а не нормальный оборот. Статус сети отделяет этаж Wi-Fi от этажа TCP: сеть жива, а зазоры есть — сокет. Код возврата connect направляет по адресу: минус два — конфиг брокера, четвёрка — пароль, разбор кодов я делал в уроке про статусы подключения. К этим трём строкам я добавил четвёртую — счётчик разрывов за сутки: на домашнем Wi-Fi моя норма ноль-два, на LTE-узле десятки; когда домашний счётчик перескочил за десяток, выяснилось, что сосед поставил микроволновку вплотную к роутеру — числа находят причину быстрее догадок.
Чего таймаут не лечит
Честная граница метода: короткий таймаут ускоряет обнаружение беды, но не лечит её причины. Мёртвый модем, перепрошивающийся роутер, зависший стек Wi-Fi на плате — от них setSocketTimeout не спасает, он лишь делает стояние коротким и заметным. Против глухого зависания самой платы последняя линия — сторожевой таймер: он сбрасывает чип, когда loop молчит дольше восьми секунд, и о ловушке вечной перезагрузки я предупреждал в уроке про watchdog-будильник. Лестница получается четырёхступенчатой: таймаут ловит тишину сокета, машина состояний переставляет попытки, копилка хранит показания, сторож добивает полное зависание — и каждая ступень дешёвая, пока стоит на своём месте.
Согласование с остальным домом — вторая половина практики. Keepalive в connect и таймаут сокета выбираются парой, и эту пару стоит прописать в комментарии рядом с setServer: через полгода никто не вспомнит, почему пинг пятнадцать секунд, а ожидание четыре. И не ставьте таймаут в ноль в надежде на «неблокирующую библиотеку»: PubSubClient в этой части синхронный по конструкции, и единственная честная неблокирующая стратегия — короткое ожидание плюс свои таймеры вокруг, что мы и собрали.
| Беда | Кто заметит первым | Симптом и лечение |
|---|---|---|
| Wi-Fi умер совсем | Плата: статус сети падает за секунды | автомат восстановления сети; попытки connect до WL_CONNECTED бессмысленны |
| Сокет полуоткрыт, сеть жива | Дашборд: волю раздал брокер раньше платы | плате — зазор loop в журнале и короткий setSocketTimeout |
| Брокер жив, но тормозит | Никто: соединение формально цело | зазоры подрастут; слишком короткий таймаут даст ложные разрывы — держите 3-5 с |
- 1. Сколько по умолчанию ждёт PubSubClient внутри сетевой операции — connect или чтение сокета?
- 2. Сеть жива, роутер умер тихо, плата шлёт PINGREQ. Что происходит дальше?
- 3. Мой потолок зазора loop в скетче контроля — 150 мс. О чём зазор в 300 мс говорит уверенно?
- 4. Почему я не ставлю setSocketTimeout(1) при keepalive 15?
- 5. Пятнадцать секунд на попытку connect при попытках каждые пять секунд — во что это превращает loop?
- MQTT_SOCKET_TIMEOUT по умолчанию — пятнадцать секунд; накрывает connect и чтение в loop, setSocketTimeout задаётся в секундах.
- Half-open сокет: сеть жива, соединение мертво; брокер раздаёт волю раньше, чем плата замечает беду.
- Мой рабочий таймаут — 3-5 секунд: в три-четыре раза меньше keepalive; секунда на паршивой связи даёт ложные разрывы.
- Зазор loop из пары millis-меток — датчик стояния: сотни миллисекунд это чтение сокета, единицы — норма.
- connect блокирующий: при пятнадцатисекундном таймауте и попытках раз в пять loop стоит почти всегда — укорачивайте.
Частые вопросы по теме урока
- Сколько по умолчанию ждёт PubSubClient внутри сетевой операции — connect или чтение сокета?
- MQTT_SOCKET_TIMEOUT по умолчанию — пятнадцать секунд, потолок и для connect, и для чтения в loop. setSocketTimeout принимает секунды и укорачивает оба случая. Пятнадцать секунд
- Сеть жива, роутер умер тихо, плата шлёт PINGREQ. Что происходит дальше?
- Это half-open: ни FIN, ни RST плата не получила, TCP ретранслирует. У брокера окно keepalive короче пятнадцати секунд клиента, поэтому дашборд краснеет раньше самой платы. Сокет полуоткрыт: плата ждёт ответ до своего таймаута, брокер раздаёт волю раньше
- Мой потолок зазора loop в скетче контроля — 150 мс. О чём зазор в 300 мс говорит уверенно?
- Нормальный оборот loop — единицы миллисекунд; сотни — это блокирующее чтение. Строка в журнале превращает зависание из слуха в число. Loop стоял в чтении сокета — соединение неблагополучно
- Почему я не ставлю setSocketTimeout(1) при keepalive 15?
- Таймаут короче реальных задержек связи рвёт живые соединения. Правило: таймаут в три-четыре раза меньше keepalive, практический минимум — две-три секунды. При паршивой связи PINGRESP не успевает — ложные разрывы каждый пинговый интервал
- Пятнадцать секунд на попытку connect при попытках каждые пять секунд — во что это превращает loop?
- connect блокирующий: каждый вызов съедает до таймаута, и попытки почти не разнимаются. Короткий таймаут оставляет датчикам воздух между попытками. В сплошное стояние: датчики и сирена оглохли