У каждого моего MQTT-узла есть приёмка: после первой прошивки я вырываю из розетки блок питания и смотрю в открытый mosquitto_sub. Если через полминуты пришло offline — узел принят. Ритуал вырос из шрама: гаражный узел лежал трое суток, а дашборд все трое уверенно показывал online по retained-сообщению, которое плата отправила при последнем подключении. Статус живёт у брокера дольше устройства, и без противовеса он врёт красиво и долго.
Противовес — пара retained плюс last will, которую повторяет почти каждый проект умного дома, от самодельных теплиц до Home Assistant. Схема: при подключении плата публикует online с retain, а в connect оставляет завещание offline с тем же retain. Тогда у статусного топика всегда есть актуальное значение — кем бы его ни написали: платой или брокером.
Как работает само завещание и почему брокер ждёт полтора окна keepalive, мы разобрали в прошлом уроке про контроль связи. Здесь — прикладная сторона: полный скетч статуса, тонкости QoS у всех трёх публикаций и тест вилкой, который я не пропускаю ни разу.
Полный скетч статуса: три публикации на одном топике
За жизнь узла статусный топик dacha/balcony/status наполняется максимум тремя способами. Первый — birth: плата подключилась и сама публикует online с retain. Второй — штатный offline: перед disconnect его шлёт скетч, например перед уходом в глубокий сон. Третий — will: брокер исполняет завещание, когда соединение умерло без прощания. Три автора, один топик, два значения, и ни одного неопределённого состояния. Словарь договорите до первой прошивки: online и offline, а не 1 и 0, не true и false, не ONLINE — строки сравниваются буквально, и смешение форматов даёт агрегатору два мнимых состояния вместо одного.
Завещание в PubSubClient задаётся прямо в connect — отдельного вызова для него в библиотеке нет. Расширенная форма принимает имя, затем топик завещания, его QoS, флаг retained и payload: client.connect(ID, TOP_STATUS, 1, true, offline) читается как обещание — если замолчу, опубликуй в статусный топик offline с QoS 1 и retain. Публикация birth у этой формы своя, обычная: client.publish с третьим аргументом true, который включает retained. Если connect не удался, причину печатает state() — коды от минус четырёх до пяти мы расписывали в первом скетче PubSubClient.
А вот QoS у публикаций устроен не так, как хочется, и об этом в учебниках молчат. У publish в PubSubClient параметра QoS нет вовсе — библиотека публикует только с нулевым уровнем. Подписка умеет просить QoS 1, и завещание я задаю с QoS 1: брокер доставит его агрегатору с подтверждением, потому что гарантированный уровень равен минимуму из QoS публикации и подписки. Birth остаётся QoS 0 с retain — единственная незастрахованная публикация, и её страхует сам retain: последнее значение лежит у брокера, и после reconnect любой агрегатор перечитает его. Насколько реально потерять birth: на ровном Wi-Fi почти никогда, у меня на балконе за месяц счёт пошёл на единицы из десятков тысяч пакетов; на границе сигнала, через две стены из газобетона, потери шли уже пачками. Именно поэтому страховка лежит не на QoS 0, а на retained-значении, которое никуда не девается.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const char* ID = "dacha-balcony-1";const char* TOP_STATUS = "dacha/balcony/status"; // один топик на всю жизнь unsigned long lastHb = 0; void reconnect() { while (!client.connected()) { // will: топик, QoS 1, retained, payload offline if (client.connect(ID, TOP_STATUS, 1, true, "offline")) { client.publish(TOP_STATUS, "online", true); // birth: с retain Serial.println("birth: status=online"); client.subscribe("dacha/pump/on", 1); } else { Serial.print("state="); Serial.println(client.state()); delay(3000); } }} void setup() { Serial.begin(115200); WiFi.begin("HomeNet", "supernet123"); while (WiFi.status() != WL_CONNECTED) { delay(500); } client.setServer("192.168.1.50", 1883); reconnect();} void loop() { if (!client.connected()) { reconnect(); } client.loop(); unsigned long now = millis(); if (now - lastHb >= 30000) { // heartbeat без retain lastHb = now; client.publish("dacha/balcony/hb", "1"); } // плановый уход — только в этом порядке: // client.publish(TOP_STATUS, "offline", true); // client.disconnect();}Три строки, на которых держится вся система. Во-первых, will живёт в каждом connect: reconnect переподключается с тем же обещанием, ничего дополнительно задавать не нужно, и даже если брокер за ночь перезапускался, первая попытка клиента придёт уже с завещанием. Во-вторых, подписку я восстанавливаю там же, после connect — это правило из урока про команды и callback, при чистой сессии брокер теряет подписки на каждом переподключении. В-третьих, топик у will и birth совпадает до байта.
Третий пункт стоит истории. Мой самый долгий тупик: в завещании я написал balon вместо balcony. Birth уходил в один топик, will — в другой, и агрегатор видел два устройства: одно всегда online, второе всегда offline. Дашборд выглядел правдоподобно, поэтому жил с этим месяц, пока я не запустил в консоли брокера подписку с маской dacha/# — приём из урока про установку Mosquitto. Она печатает топики целиком, и лишняя строка бросилась в глаза. С тех пор имена топиков у меня — константы в начале скетча, а не строки, вбитые в вызовы.
И про указатели: топик и payload завещания библиотека хранит указателями, а не копиями — то же касается имени клиента. Передадите локальную String и её c_str — после выхода из функции указатель повиснет в воздухе, а однажды брокер опубликует завещание из мусора. Мои топики — глобальные const char*, и советую то же: заводите их константами в начале скетча, как в примере выше.
Тест вилкой: приёмка узла
Ритуал из трёх тестов. Первый — вилка: вырываю питание, смотрю в консоль с подпиской на статусный топик, засекаю. Через двадцать-двадцать пять секунд приходит offline, после возврата питания — online. Второй тест — роутер: перегружаю его и слежу, как узел сам переподключается и восстанавливает birth; заодно проверяю, жив ли автореконнект сети, которым мы занимались в уроке про Wi-Fi и автореконнект. Условие, при котором тест роутером считается пройденным: розетка была обесточена дольше двух минут — за это время TCP у клиента умирает по таймауту, и после возврата сети узел обязан опубликовать birth заново, а не надеяться, что соединение выживет само.
Третий тест — штатный уход: запускаю ветку с публикацией offline и disconnect и убеждаюсь, что статус меняется мгновенно, без ожидания окна, а will при этом молчит. Порядок именно такой: сначала publish, потом disconnect — наоборот не выйдет, соединение уже закрыто. Если в панели статус виден, а в консоли брокера тишина, проверьте, в том ли топике вы подписались: мой второй любимый тупик после опечатки в имени.
Почему вилка, а не кнопка reset в мониторе порта: для платы это одно и то же, но вилка — честный сценарий дачи, где электричество мигает само, и я хочу знать, что произойдёт именно так. Отдельного слова заслуживает брокер: перезапуск mosquitto без сохранения состояния стирает все retained — дашборды встают пустыми до ближайших birth. Это не аргумент против retain, это аргумент включить сохранение состояния и перезапускать брокер реже, чем узлы переподключаются: в mosquitto за это отвечает строка persistence true в конфиге, и после неё рестарт сервиса перестаёт быть катастрофой.
| Конфигурация | Что видит новый дашборд | Что при отвале | Вердикт |
|---|---|---|---|
| birth без retain | пустую метку до ближайшей публикации | offline по will, но не всем | полумера |
| will без retained | пустоту после рестарта брокера | offline может потеряться | полумера |
| retain без will | старое значение сразу | online висит вечно — врать можно трое суток | опаснее всего |
| will QoS 1 + retained, birth retained | актуальный статус мгновенно | offline за полтора окна с подтверждением | рабочая схема |
Симулятор разыгрывает обе роли, как в прошлый раз, плюс третьего участника: тумблер на макетке играет нового дашборда, который подключился к брокеру. Переключите его при живой плате — retained-значение приезжает мгновенно, до всякого heartbeat. Константа RETAIN_ON в скетче — выключатель этого поведения: поставьте ноль и посмотрите, сколько времени дашборд остаётся слепым. Хронологию в мониторе читайте как тест: при живой плате строка DASH приходит мгновенно — это retained-значение, а не новая публикация; после will тот же тумблер достаёт уже offline, написанный брокером.
И финальный штрих к картине: метка статуса, которая мгновенно оживает на панели телефона, — это ровно та связка, что мы собрали. Про саму панель — виджеты, кнопки и графики MQTT Dash — следующий урок.
- Поставьте RETAIN_ON в ноль, перезапустите и пощёлкайте тумблер: новый дашборд стоит с пустой меткой. Верните единицу и сравните, сколько времени дашборд остаётся слепым в каждом случае.
- Оборвите связь кнопкой D2, дождитесь строки BROKER и только потом включите тумблер: новый дашборд получает offline, которого писал не клиент, а брокер — так и работает will.
- На живой плате прогоните тест вилкой: подписка на статусный топик в консоли, питание, секундомер. Сверьте свои двадцать-двадцать пять секунд с полтора окна keepalive.
- Добавьте штатный уход: публикацию offline с retain и disconnect по нажатию кнопки — и проверьте в консоли, что will молчит, а offline приходит мгновенно.
- Три публикации на одном топике: birth online от скетча, штатный offline руками перед disconnect, will offline от брокера при некрасивом отвале
- Will задаётся в connect: топик, QoS, retained и payload; reconnect уносит то же обещание с собой
- publish в PubSubClient всегда QoS 0; третий аргумент — retained, а не QoS; подписку агрегатора ставьте с QoS 1, чтобы получать will с подтверждением
- Топик will и birth совпадает до байта; топики держите глобальными константами — библиотека хранит указатели, не копии
- Тест вилкой: offline за полтора окна, online сразу после birth; рестарт брокера без persistence стирает retained
- 1. Плата молча умерла: вилку вырвали, попрощаться не успела. Кто и когда опубликует offline?
- 2. Что должно совпадать у birth и will до байта?
- 3. Почему will я задаю с QoS 1, хотя publish в PubSubClient умеет только QoS 0?
- 4. Новый подписчик подключился через сутки после отвала платы. Конфиг: will с retain, birth с retain. Что он получит?
- 5. client.publish(топик, payload, 1) — что на самом деле делает третий аргумент?
Частые вопросы по теме урока
- Плата молча умерла: вилку вырвали, попрощаться не успела. Кто и когда опубликует offline?
- Некрасивый отвал — работа брокера: он исполняет will после полутора окон keepalive. Сам дашборд ничего не догадывается, он лишь читает топики. Брокер по завещанию после полутора окон keepalive
- Что должно совпадать у birth и will до байта?
- Разные топики у birth и will дают агрегатору два мнимых устройства: одно всегда online, второе всегда offline. Опечатку я вылавливал месяц. Статусный топик
- Почему will я задаю с QoS 1, хотя publish в PubSubClient умеет только QoS 0?
- Гарантированный уровень доставки равен минимуму из QoS публикации и подписки. Will с QoS 1 плюс подписка агрегатора с QoS 1 дают подтверждённую доставку. Брокер доставит will подписчику с подтверждением, а birth страхует retained
- Новый подписчик подключился через сутки после отвала платы. Конфиг: will с retain, birth с retain. Что он получит?
- Will был задан с retain: брокер хранит offline как последнее значение статусного топика и отдаёт каждому новому подписчику сразу. offline от брокера с retain
- client.publish(топик, payload, 1) — что на самом деле делает третий аргумент?
- У publish в PubSubClient нет параметра QoS вовсе: третий аргумент — булев флаг retained. Единица там делает сообщение постоянным, а не надёжным. Включает retained: брокер запомнит значение