В феврале тепличный термометр замер на отметке 14.2 и прожил на этой цифре шесть часов. Дашборд рисовал ровную линию, я видел её в семь утра, в обед и в четыре, и делал выводы: потепление задерживается. Страшное началось вечером, когда легли ростки, а причина нашлась скучная: роутер в сарае завис за ночь, плата так и не переподключилась, и связка с брокером умерла около полуночи. Дашборд не отличил мёртвый узел от живого — в MQTT тишина выглядит как отсутствие новостей, а не как тревога.
Шесть часов я доверял числу, которое никто не обновлял. После того случая каждый мой узел носит два признака жизни. Первый — статус: маленький топик вида dacha/balcony/status со значениями online и offline, причём за offline отвечает не плата, а брокер. Второй — heartbeat, короткое «я жив» в соседний топик по таймеру. Сегодня собираем живой индикатор датчика целиком: кто пишет online, кто пишет offline и что в этот момент видит агрегатор.
Про завещание — last will — в первом уроке про устройство протокола было одно предложение: брокер пообещает опубликовать в статусный топик offline, если клиент отвалится без прощания. Теперь разберём до болтов: как клиент оставляет наказ, в какой момент брокер его исполняет и почему не мгновенно.
Завещание: кто и когда его исполняет
Механика такая. При каждом connect клиент передаёт брокеру сверх имени четыре вещи: топик завещания, его QoS, флаг retained и payload. Брокер складывает их в паспорт соединения и ждёт. Пока клиент жив и укладывается в окно keepalive, завещание никто не трогает. Но стоит соединению умереть некрасиво — питание срезали, Wi-Fi отвалился, роутер повис, плата ушла в перезагрузку без прощания, — и брокер сам публикует завещанный payload в завещанный топик от имени клиента.
Слово «некрасиво» здесь ключевое. Красивый уход — это client.disconnect(): клиент сам прощается, и завещание отменяется. Моя плата перед плановой перезагрузкой по расписанию шлёт offline руками и делает disconnect — дашборд узнаёт честно. Вырванная вилка, зависший роутер и esp_restart() — некрасивые смерти, именно на них брокер исполняет will.
Мгновенности ждать не надо. Брокер не телепат: он обнаруживает смерть по окну keepalive. Клиент шлёт пинг внутри client.loop() при дефолтных пятнадцати секундах — это мы разбирали в уроке про публикацию показаний с ESP32, — а по спецификации не дождавшийся брокер ждёт ещё полтора окна, прежде чем объявить клиента мёртвым. На практике: вилка вырвана — через двадцать-двадцать пять секунд в статусном топике появляется offline. Полминуты задержки для термометра — смешно; для охранной сигнализации я подстраиваю и keepalive, и порог тревоги. Если хочется быстрее, у свежей библиотеки есть setKeepAlive: вызов client.setKeepAlive(10) до connect сжимает окно до десяти секунд, пинг пойдёт вдвое чаще, а will сработает за пятнадцать секунд — для тревожных узлов мой компромисс именно десять, дальше рост служебного трафика уже не оправдан.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const char* ID = "dacha-balcony-1";const char* TOP_STATUS = "dacha/balcony/status";const char* TOP_HB = "dacha/balcony/hb"; unsigned long lastHb = 0;const unsigned long HB_MS = 30000; // heartbeat раз в 30 секунд void setup() { Serial.begin(115200); WiFi.begin("HomeNet", "supernet123"); while (WiFi.status() != WL_CONNECTED) { delay(500); } client.setServer("192.168.1.50", 1883); 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"); } else { Serial.print("state="); Serial.println(client.state()); delay(3000); } }} void loop() { client.loop(); unsigned long now = millis(); if (now - lastHb >= HB_MS) { lastHb = now; client.publish(TOP_HB, "1"); // пульс не хранят: без retain }}Разбор без спешки, потому что тут каждая строка когда-то была моей граблёй. Birth-сообщение online публикует сам клиент, сразу после успешного connect, с retained — дашборд, открывшийся через час, увидит актуальный статус, а не пустую метку. Offline приходит двумя путями: штатный скетч шлёт руками перед disconnect, аварийный брокер по завещанию. Топик у всех трёх публикаций один, до байта: dacha/balcony/status. Разница только в том, кто нажал кнопку.
Heartbeat в соседнем топике — усиление, а не замена. LWT ловит обрыв связи, но не ловит полуживое устройство: плата подключена, реле щёлкает, а датчик завис и три часа шлёт бессмыслицу. Соединение живо, завещание молчит — зато в dacha/balcony/hb тишина. Агрегатор смотрит на оба признака: статус отвечает за связь, heartbeat — за то, что скетч вообще крутится. Период тридцать секунд — это 2880 сообщений в сутки, при пакете в пятьдесят-шестьдесят байтов около 160 КБ: брокер такого чиха не заметит. Таймер ставлю на millis формой now - lastHb >= HB_MS, без delay в главном цикле — приём из урока про millis вместо delay.
Заметьте: heartbeat я публикую без retain. Каждое «я жив» имеет смысл только в момент отправки, а брокер хранит последний retained на топик и раздаёт его каждому новому подписчику — представьте пульс двухнедельной давности, который дашборд считает свежим после рестарта брокера. Правило простое: retained — для состояний, которые живут, пока их не сменили; пульс — для того, что устаревает за тридцать секунд.
Агрегатор: одна подписка на весь дом
Панель со статусами всех устройств пишется одним subscribe. Топики у меня подчиняются схеме дом-комната-имя, поэтому маска dacha/+/status накрывает весь дом: термометр в теплице, насос в сарае, узел на веранде. Добавили устройство — оно появилось в списке само, без правок агрегатора. Маски мы разбирали в уроке про подписку и callback: там слушали команды, здесь слушаем самочувствие.
Самый дешёвый агрегатор — терминал. mosquitto_sub -t dacha/+/status -v на компьютере или на той же Raspberry, где живёт брокер, — и весь дом в одном окне: строки online и offline сменяют друг друга по мере жизни узлов. Для стены я потом поднимаю страничку посерьёзнее, но она — тот же подписчик, только с кнопками. Консоль честнее любого интерфейса: она ничего не приукрашивает.
Агрегатор проверяется и без платы. Вбейте вручную mosquitto_pub -t dacha/test/status -m offline -r — в консоли агрегатора мигнёт новая строка, а брокер запомнит retained. Уберите её той же командой с пустым payload и флагом -r, когда наиграетесь: тестовые статусы имеют привычку вечно висеть в списке живых узлов и портить картину.
И третья деталь, на которую я потратил лишнюю итерацию: агрегатор должен показывать не только offline, но и «слишком долго без heartbeat». Статус врёт ровно в одном сценарии: связь вернулась, birth потерялся на помехах — и дашборд рисует offline для живой платы до ближайшего reconnect. Двойной контроль ловит и это: если статус online, а hb молчит дольше двух минут, значит внутри скетча что-то гниёт, и это видно одним взглядом.
| Признак | Топик | Что ловит | Чего не видит |
|---|---|---|---|
| Статус online/offline | dacha/balcony/status | любой обрыв связи за 20–25 секунд | полуживую плату при живом соединении |
| Heartbeat | dacha/balcony/hb, раз в 30 с | зависший скетч при живой связи | мгновенный обрыв — до 30 секунд тишины |
| Retained у статуса | тот же status | мгновенную картину для нового дашборда | свежесть: значение может быть часовым |
Тупик из первых попыток: я завёл heartbeat с периодом в пять минут и ловил ложные тревоги. На перегруженном Wi-Fi 2.4 ГГц одиночные потери пакетов — норма, а QoS 0 подтверждений не даёт: один потерянный пульс выглядел как смерть. Лечится на стороне агрегатора: тревога не по одному пропуску, а по трём подряд. Шесть минут тишины для термометра не критичны; для узла с насосом я ставлю пульс чаще и порог ниже. Арифметика цены: при периоде в тридцать секунд узел кладёт в сеть 2880 пульсов в сутки, при десяти секундах — 8640, то есть около полумегабайта при пакете в пятьдесят-шестьдесят байтов. Один такой узел брокер не заметит, десяток — уже заметит. Условие простое: чем дороже минута незнания, тем короче период, и каждое сокращение вы платите брокеру.
Симулятор ниже собирает всю конструкцию в одном окне: индикатор платы, кнопка обрыва связи, строка виртуального брокера. Полный скетч статуса с тонкостями QoS и retained — тема следующего урока про индикатор офлайн; здесь — каркас и ритм. Нажмите кнопку на макетке: светодиод замигает, пока брокер ждёт окно, потом погаснет — will исполнен.
- Нажмите кнопку D2 и посчитайте по строкам монитора, сколько проходит от обрыва до строки BROKER: в симе окно 6 секунд, на живом брокере с keepalive 15 — полтора окна, около 22.
- Поменяйте HB_MS с 4000 на 8000 и сравните плотность hb=1 в мониторе. Затем пересчитайте, сколько сообщений в сутки даст период 30 секунд и сколько это трафика при пакете в 60 байтов.
- Добавьте второй узел: раз в 8 секунд публикуйте dacha/greenhouse/status со значением online вместе с birth — и проверьте, что один агрегатор с маской dacha/+/status увидел бы оба.
- Сломайте систему сознательно: уберите publish online из ветки восстановления и найдите комбинацию, при которой агрегатор показывает offline живой плате. Это те дыры, что закрывает двойной контроль.
- LWT задаётся при каждом connect: топик, QoS, retained и payload; исполняется брокером только при некрасивом отвале — вилка, Wi-Fi, перезагрузка без disconnect
- Красивый client.disconnect() завещание отменяет: перед плановым уходом шлите offline руками
- При keepalive 15 секунд брокер ждёт полтора окна: offline появляется через 20–25 секунд, не мгновенно
- Birth online публикует сам клиент после каждого connect, с retain; heartbeat — без retain, потому что пульс устаревает
- Агрегатор подписан одной маской dacha/+/status и смотрит два признака: статус за связь, heartbeat за живой скетч
- 1. Плата ушла в перезагрузку по питанию. Что произойдёт со статусным топиком?
- 2. client.disconnect() перед плановой перезагрузкой — что будет с will?
- 3. Через сколько после вырывания вилки появится offline при keepalive 15 секунд?
- 4. Почему heartbeat публикуют без retain?
- 5. Что увидит агрегатор с одной подпиской dacha/+/status при трёх узлах дома?
Частые вопросы по теме урока
- Плата ушла в перезагрузку по питанию. Что произойдёт со статусным топиком?
- Перезагрузка по питанию — некрасивая смерть: клиент не успевает сделать disconnect, и брокер исполняет завещание, заданное при последнем connect. Брокер сам опубликует завещание в статусный топик
- client.disconnect() перед плановой перезагрузкой — что будет с will?
- Штатный disconnect — это прощание клиента с брокером: завещание не исполняется. Поэтому перед плановым уходом offline отправляют руками. Брокер считает уход штатным и не публикует завещание
- Через сколько после вырывания вилки появится offline при keepalive 15 секунд?
- Брокер обнаруживает смерть по окну keepalive и по спецификации ждёт полтора окна, прежде чем объявить клиента мёртвым и исполнить will. Через 20–25 секунд — полтора окна keepalive
- Почему heartbeat публикуют без retain?
- Retained-значение живёт у брокера и раздаётся каждому новому подписчику. Пульс устаревает за период тишины, поэтому его не хранят. Брокер хранит один retained на топик, и пульс двухнедельной давности выглядел бы свежим
- Что увидит агрегатор с одной подпиской dacha/+/status при трёх узлах дома?
- Маска плюс в уровне топика накрывает все комнаты сразу: один subscribe получает статусы всех узлов, новые устройства появляются сами. Статусы всех трёх узлов одним вызовом subscribe