Панель в доме сутки показывала зелёный датчик теплицы, а теплица сутки лежала: отгорела клемма на блоке питания, и плата молча ушла в темноту. TCP умер без прощальных пакетов, дашборд честно рисовал последнюю точку — 23,4 °C, — и я узнал о беде, когда пошёл за рассадой и уронил лейку на сухую грядку. В тот вечер появилось правило, живущее у меня с тех пор: узел не имеет права исчезать молча. Протокол MQTT дал для этого два механизма — Last Will и retain — и оба прописываются в аргументах connect, а не в моих циклах.
Воля — сообщение, которое брокер отправит вместо клиента, если тот пропал без вежливого прощания: на заданный топик, с заданным QoS и retain-флагом. Retain — флаг у любого publish: брокер хранит последнее значение топика у себя и отдаёт его каждому новому подписчику ещё до первой свежей публикации. Саму волю на уровне протокола и индикатор связи на светодиоде панели я разбирал в уроке про last will — здесь не пересказываю, а собираю из двух флагов то, ради чего их берут: статусную схему online/offline для Home Assistant.
Разберу пятиарговый connect, парное сообщение-приветствие, окно тишины брокера и главную ловушку схемы — волю, которая молчит при аккуратном прощании. Все числа ниже из документации PubSubClient и спецификации MQTT 3.1.1, с условиями там, где они меняются. К концу урока у вас будет статусная схема, которую панель, брокер и скетч понимают одинаково, — и знание, где она врёт.
Завещание: пять аргументов connect
В PubSubClient воля задаётся перегрузкой connect с пятью аргументами: client.connect(id, willTopic, willQos, willRetain, willMessage). Семиарговая версия вставляет логин и пароль между идентификатором и willTopic — для брокера с авторизацией, о которой я писал в уроке про пароли и ACL. willQos — уровень доставки самой воли; я ставлю единицу: статус устройства стоит одного подтверждения, а недоставленная воля при паршивой связи оборачивается вечным online на панели. willRetain — true без вариантов: без него брокер разошлёт offline только тем, кто слушал в момент смерти, а панель, переподключившаяся через час, ничего не узнает. Это тот же retained-кэш, что разобран в уроке про retained-статус, просто пишет его брокер сам, без вашего publish.
Каждому завещанию — парное приветствие, birth: при каждом удачном connect скетч публикует в тот же топик payload online, тоже с retain. Разорвётесь — брокер ляжет offline вашей волей; вернётесь — online уедет из ветки connect. Новоприбывший подписчик по retain получает последнее из двух значений мгновенно, не дожидаясь следующего события. Дефолтный keepalive в PubSubClient — пятнадцать секунд, константа MQTT_KEEPALIVE; метод setKeepAlive(seconds) меняет его на узле с модемом. По спецификации брокер отводит клиенту полтора окна: с дефолтом это двадцать две с половиной секунды тишины от последнего пакета до раздачи воли. Откуда берётся тишина и как выглядит PINGREQ — в уроке про keepalive. В пару к окну — короткий таймаут сокета: ожидание в три-четыре раза меньше keepalive, иначе каждая попытка connect съест больше времени, чем останется датчикам.
Единица в willQos выбирается и потому, что воля едет от брокера к подписчикам как обычное сообщение: с нулевым уровнем брокер отправит её один раз и не станет повторять, если панель в этот самый миг переподключалась. С готовностью поспорю: если панели живут на верной сети и переподключаются редко, хватит и нуля. Мой дом с LTE-стыком на краю участка голосует за единицу — один раз я поймал именно недоставленную волю на паршивой связи, и панель полдня красовалась зелёным над мёртвым узлом.
Обе тонкости сидят в самом пакете CONNECT, и обе я сперва проглядел. Воля едет внутри рукопожатия: willTopic и willMessage упакованы туда же, куда идентификатор и keepalive, — значит, её размер вычитается из буфера клиента наравне со всем остальным. Дефолтные 256 байт MQTT_MAX_PACKET_SIZE на ESP8266 переваривают однословное offline с запасом, а вот завещание-трактат на пару сотен байт уже конкурирует с длинным client id за место — connect в такой ситуации возвращает false без единого пояснения, и я однажды полчаса искал виновника в роутере, пока не сел считать байты. Вторая тонкость: к подписчикам воля едет с уровнем, ограниченным их собственной подпиской, — слушатель с нулевым QoS получит offline нулевым даже при моей willQos=1, брокер срезает до потолка подписчика. Ставить воле второй уровень бессмысленно вдвойне: библиотека его не поддерживает, а панель всё равно срежет.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const char* AVAIL = "home/teplica/availability"; void connectMqtt() { client.setServer(MQTT_SERVER, 1883); client.setKeepAlive(15); // дефолт тот же, тут — для читателя // пять аргументов: id, willTopic, willQos, willRetain, willMessage if (client.connect("teplica-lwt", AVAIL, 1, true, "offline")) { client.publish(AVAIL, "online", true); // birth: приветствие с retain client.subscribe("home/teplica/cmd"); }} void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); connectMqtt();} void loop() { client.loop(); if (!client.connected()) { reconnectByTimer(); // машина состояний из урока про reconnect } static unsigned long lastPub = 0; if (client.connected() && millis() - lastPub >= 60000) { lastPub = millis(); char msg[24]; int t = analogRead(A0) / 20 - 5; // условные десятые градуса snprintf(msg, sizeof(msg), "{\"t\":%d}", t); client.publish("home/teplica/temp", msg); }}Ловушка: вежливое прощание гасит волю
Главная ловушка схемы устроена протоколом, а не библиотекой: при корректном disconnect() брокер считает клиента ушедшим добровольно и волю не отправляет. Логика в этом есть — вы сами сказали «всё, я ухожу», зачем паниковать, — но первый раз она бьёт со спины: я перезагружал узлы скриптом, который перед ребутом аккуратно закрывал соединения, и недоумевал, почему панель держит online на мёртвой плате. Устройство попрощалось — воля отменена, топик остался в последнем retained-значении, а broker даже не почесался.
Из этого следуют два решения, и оба у меня в работе. Если offline при каждом рестарте прошивки нужен — не прощайтесь: роняйте плату, пусть брокер досчитает окно тишины и раздаст волю; с дефолтным keepalive это двадцать две с половиной секунды тишины. Если наоборот, статус должен пережить плановый ребут — публикуйте offline руками прямо перед disconnect(): подписчики получат честную строку сразу, а не двадцатисекундную паузу с последующим хлопком. Воля срабатывает ровно на три случая: тишина дольше полутора keepalive, разрыв TCP без прощальных пакетов — выдернули питание, умер роутер, — либо сброс сокета сетевым стеком.
Отвал Wi-Fi-этажа плата видит и сама: статус сети падает за секунды, и автомат восстановления сети подхватывает переподключение. Но подписчики в доме узнают о беде именно от брокера и его воли — плате, которой уже нет, докладывать нечем. Две стороны одного отвала, два разных оповещателя: это стоит держать в голове, когда соблазн написать «Wi-Fi отвалился, значит, и статус сам уйдёт» становится сильным.
Отдельная беда — одинаковый client id на двух узлах: брокер держит одну сессию на идентификатор, второй клиент выгоняет первого, выгнанный раздаёт волю, затем подключается снова и публикует birth. Панель живёт маятником online/offline каждые несколько секунд, а вы ищете антенну, хотя виноват копипаст прошивки. Я собрал такое случайно, скопировав скетч на два узла без смены идентификатора; идентификатор уникален на весь брокер, и проверять его нужно раньше, чем качество связи.
| Как умирает узел | Что видит подписчик | Что делать |
|---|---|---|
| Питание выдернули, Wi-Fi умер | через ~22 с тишины (keepalive 15 с × 1,5) брокер раздаёт волю: availability = offline | штатный случай — воля сделана ровно для него |
| Сокет разорвался без FIN, роутер упал | то же окно: брокер ждёт тишины и отдаёт волю | плате — короткий setSocketTimeout, панелям — воля |
| disconnect() перед рестартом | воли нет: топик остаётся в старом значении, часто online | не прощайтесь или публикуйте offline сами до disconnect |
| Перезапустился брокер | сессия с волей ушла из памяти брокера | millis-таймер reconnect вернёт узел, birth оживит статус |
Чего воля не знает
Воля говорит «соединение умерло» — и ничего про датчик. Плата жива, Wi-Fi жив, брокер доволен, а термистор отдал 85 °C, потому что провод отгнил у клеммы: availability зелёный, данные мусор. Статус связи и валидность значений — разные этажи, и второй воля не ловит: диапазоны и правдоподобность проверяйте в скетче до публикации, иначе панель будет честно рисовать бред со счастливым зелёным кружком.
Ставлю волю каждому узлу по умолчанию: цена — пять аргументов в connect и одна birth-публикация, а выигрыш — дом, который сам докладывает о похоронах. Случай, когда можно без неё, существует: панель, детектирующая молчание по отсутствию свежих данных и краснеющая от собственного таймера. Но один узел на батарейке с дремлющим LTE-модулем переубедил меня быстрее, чем я дописал этот таймер: сводку, кто из дома жив, я собираю подпиской на availability-решётку — как, показано в уроке про агрегатор статусов.
Панель для статуса не обязана быть панелью вовсе: светодиод на стене, горящий, пока узел жив, — тот же подписчик на availability-топик, и воля с retain делают за него всю работу. Такой маячок ничего не знает про протокол отвала, не держит reconnect-логики и живёт на чём угодно — хоть на малине, хоть на второй плате. Поэтому волю я считаю инфраструктурой дома, а не удобством конкретного скетча: настроил один раз — пользуются все слушатели, включая тех, которых вы ещё не написали.
Дырка в схеме, о которой я узнал на батареечном узле в парнике, — дремота. Плата, спящая по десять минут между публикациями, не тикает keepalive, пока спит: брокер досчитывает свои двадцать две с половиной секунды тишины и раздаёт волю, панель гасит живой узел, тот просыпается, birth возвращает online — и круг повторяется. Шесть циклов сна в час — шесть ложных похорон, и на панели они неотличимы от настоящего отвала. Рецептов честных два. Первый — keepalive больше семи минут, чтобы окно тишины пережило сон: работает, но и настоящий отвал теперь обнаруживается минутами, а не полусотней секунд. Второй, мой выбор, — спящим узлам воли не ставить вовсе: статус держит публикация online с retain при каждом пробуждении, а тревогу поднимает приёмник по свежести данных — молчание дольше двух циклов сна означает, что в парнике пора идти.
- 1. Сколько тишины вытерпит брокер перед раздачей воли при keepalive 15 секунд?
- 2. Скетч перед перезагрузкой вызвал client.disconnect(). Что уйдёт подписчикам?
- 3. Зачем воле флаг willRetain=true?
- 4. Плата вернулась, connect прошёл. Что обязан сделать скетч для живого статуса?
- 5. Какую беду статус availability не заметит?
- Воля задаётся аргументами connect: willTopic, willQos, willRetain, willMessage; моя настройка — QoS 1 и retain true.
- Каждой воле — парный birth: online с retain при каждом connect, иначе retained offline похоронит живой узел.
- Брокер терпит полтора keepalive: с дефолтом 15 с это 22,5 с тишины; keepalive меняет setKeepAlive, дефолт — константа библиотеки.
- Корректный disconnect() гасит волю: нужен offline при рестарте — публикуйте сами или не прощайтесь вовсе.
- Availability говорит о связи, не о датчике: гнилой провод при живой сети остаётся зелёным — валидация значений отдельный этаж.
Частые вопросы по теме урока
- Сколько тишины вытерпит брокер перед раздачей воли при keepalive 15 секунд?
- По спецификации брокер держит полтора keepalive: 15 с × 1,5 = 22,5 с тишины, потом воля уходит на willTopic. 22,5 секунды: полтора окна
- Скетч перед перезагрузкой вызвал client.disconnect(). Что уйдёт подписчикам?
- disconnect() — добровольный уход, брокер волю гасит. Нужен честный offline при рестарте — публикуйте его сами до прощания. Ничего: вежливое прощание отменяет волю
- Зачем воле флаг willRetain=true?
- Без retain воля достанется только слушавшим в момент смерти; переподключившаяся панель останется в неведении с зелёным кружком. Брокер хранит offline и отдаст панелям, которые подключатся позже
- Плата вернулась, connect прошёл. Что обязан сделать скетч для живого статуса?
- Воля оставила в брокере retained offline; birth-публикация online с retain перезапишет её. Забытый birth держит живой узел серым. Опубликовать birth: online с retain в тот же топик
- Какую беду статус availability не заметит?
- Воля про соединение, не про данные: живая плата с гнилым проводом публикует мусор с зелёным статусом. Диапазоны проверяйте до публикации. Отвалившийся провод датчика при живой связи