Светильником в коридоре я управлял с телефона через брокер: телефон отправлял единицу в home/hall/light, плата щёлкала реле. Однажды я передёрнул автомат в щитке, плата перезагрузилась — и светильник остался выключенным, хотя телефон показывал «вкл». Команда была отправлена ещё вчера, брокер её помнил, а плата о ней не знала: в момент команды она не была подключена.
Вторая половина той же истории хуже. После перезагрузки плата вообще перестала слушаться: подписка на командный топик была написана в setup(), после первого же reconnect() она не восстанавливалась — соединение жило, а команды уходили в пустоту. PubSubClient не хранит список подписок: разорвали связь — забудь, что ты слушал. Это не дефект библиотеки, а свойство протокола с чистой сессией, и обходится оно одним правилом: подписка живёт там же, где подключение.
Урок про рестарт без потерь: куда поставить subscribe, чтобы он переживал каждый обрыв; как после подписки забрать у брокера retained-статусы — механику флага retain мы уже разбирали — и привести локальное состояние в соответствие с домом; и где граница приёма — что теряется при рестарте безвозвратно, а что возвращается само.
Подписка живёт в reconnect
Правило простое: каждая ветка кода, которая вызывает client.connect(), обязана сразу после успеха вызвать client.subscribe() для каждого командного топика. У меня это одна функция reconnect(): внутри подключение, внутри — подписки. Первый вызов происходит из setup(), все последующие — из цикла при обрыве, и каждая дорога проходит через один шлюз. Логика самого reconnect с паузами по millis разобрана в уроке про reconnect с backoff — здесь к нему добавляются только строки subscribe.
Почему брокер забывает подписки: клиент подключается с чистой сессией, и на каждый disconnect брокер выбрасывает подписки вместе с ней. Это осознанный компромисс протокола: подписки и очередь сообщений стоят памяти на брокере, и держать их вечно для клиента, который может не вернуться, расточительно. Постоянные сессии существуют, но PubSubClient их по умолчанию не ведёт, и для домашнего зоопарка я выбираю явное: короткая сессия, подписка в reconnect, восстановление статусов retained-сообщениями.
Отдельный слой — воля и имя клиента: setWill и connect тоже живут в reconnect(), потому что каждый новый connect перезаключает договор с брокером заново. Проверено на себе: перенос setWill в setup() работает до первого обрыва, после которого плата выходит в сеть молча — без завещания offline, и дашборд показывает её живой, когда она уже сутки как мертва. Всё, что относится к договору с брокером — сервер, воля, подписки, callback, — я держу в одном месте, и порядок строк в нём одинаков для всех узлов дома.
Проверка на грабли занимает минуту: откройте mosquitto_sub на командный топик, перезагрузите роутер и посмотрите, доходит ли следующая команда до платы. Если соединение поднялось, а плата глухая — ищите subscribe, забытый в setup(). Диагностику кодов обрыва я собирал в уроке про client.state(): статус -3 после падения связи — верный признак, что подписку надо чинить.
#include <WiFi.h>#include <PubSubClient.h> const int LED = 12; // реле светильника и его статусint ledState = 0; WiFiClient net;PubSubClient client(net); void applyState(int v) { ledState = v ? 1 : 0; digitalWrite(LED, ledState ? HIGH : LOW); Serial.print("primeneno: led="); Serial.println(ledState);} void callback(char* topic, byte* payload, unsigned int length) { String msg = ""; for (unsigned int i = 0; i < length; i++) msg += (char)payload[i]; // сюда приходят и живые команды, и retained после подписки — // для состояния это одно и то же: «стало быть так» if (msg == "1") applyState(1); else if (msg == "0") applyState(0); // мусор в топике молча игнорируется: ветки else нет — осознанно} void reconnect() { while (!client.connected()) { if (client.connect("dacha-hall")) { client.subscribe("home/hall/light"); // каждый раз, как подключились Serial.println("podpisan na home/hall/light"); } else { delay(2000); // в боевом скетче пауза на millis — урок про backoff } }} void setup() { Serial.begin(115200); pinMode(LED, OUTPUT); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) delay(300); client.setServer("192.168.1.50", 1883); client.setCallback(callback); reconnect(); // и подключение, и подписка — через один шлюз} void loop() { if (!client.connected()) reconnect(); client.loop();}Callback не отличает живую команду от retained-восстановления — и это фича, а не лень: обе дороги означают «сейчас должно быть так», и состояние обязано стать одинаковым. Единственная моя оговорка: после подписки retained-пачка приходит за миллисекунды, и каждый топик дёргает callback один раз — в мониторе её полезно видеть целиком, чтобы не путать с живыми командами. Если статусных топиков много, лог на пачку ставлю с пометкой vosstanovlenie.
Формат payload держите плоским: «0» и «1» строками, без единиц и без JSON для одиночного реле. Что пришло вместо ожидаемого — мусор из кривой панели или чужая публикация в тот же топик — callback молча пропустит: ветки else нет, и это осознанно, потому что глушить реле от каждого шумового пакета хуже, чем промолчать. Белые списки и валидацию команд я разбирал в уроке про subscribe и callback — здесь они применяются как есть.
Порядок строк в setup() не случайный: client.setCallback() обязан стоять до первого reconnect(). Retained-пачка приходит сразу после subscribe и доставляется в момент вызова client.loop() — callback, назначенный позже, встретит первую пачку пустыми руками, и светильник после рестарта останется в прошлом состоянии. Мой порядок: сервер, callback, connect — и каждая новая плата проходит его без сюрпризов.
Конфликт: команда пришла, пока платы не было
Разберу конфликт на живом примере. В четырнадцать ноль-ноль вы отправили с работы «свет в коридоре: вкл». Плата в этот момент висела обесточенной после скачка в сети. Команда ушла в брокер, а у брокера никого не было дома: подписчиков нет, сообщение прожило долю секунды и умерло. Без retain сценарий закончился бы тёмным коридором вечером. Но командный топик опубликован с retain=true: брокер отдал последнее значение плате сразу после subscribe, и светильник включился сам — через три часа после команды.
Это и есть восстановление статусов: плата не помнит прошлой жизни, но брокер помнит за неё. Правило у меня теперь жёсткое: каждый статусный и командный топик публикуется с retain=true — исполнители приходят в форму сами, а дашборды перестают показывать знак вопроса после каждого рестарта. Исключение одно: телеметрия с частым периодом. Retained-температура полусутками давности хуже честной пустоты — об этой границе написано в уроке про retained-сообщения.
Моя рутина после рестарта: Wi-Fi, брокер, подписки, первая публикация снимка состояния — и только потом работа. Первая публикация — недооценённый шаг: она переводит дашборд из состояния «узел пропал» в «узел жив» без ожидания ближайшего периода, а заодно обнуляет у брокера время жизни статуса. Стоит эта рутина сотню байтов и пару миллисекунд — дешевле любой путаницы с панелями, которые спрашивают себя раз в две секунды.
Индикатором здоровья самой пары плата-брокер служит LWT: завещание offline с retain даёт дашборду правду, когда плата пропала целиком, а не только после рестарта. Связка retained-статусов с завещанием разобрана в уроке про индикатор связи — в гибриде из третьего урока этой трилогии оба работают вместе.
| Что | Переживает рестарт? | Кто держит |
|---|---|---|
| Переменные прошивки: счёт, состояния | нет | RAM — умирает вместе с питанием |
| Подписки на командные топики | нет, восстанавливаются в reconnect() | плата заново, строки subscribe |
| Последний статус каждого топика | да, если публиковался с retain | брокер |
| Сообщения QoS 1 за время простоя | нет при чистой сессии | retained вернёт только последнее значение |
| Wi-Fi-параметры | да | NVS ESP32, без вашего участия |
| Millis-отсчёт и таймеры | нет, стартуют с нуля | им и надо с нуля |
Таблица подводит к честной границе приёма: брокер возвращает только то, что было опубликовано с retain. Итог счётчика воды публиковался раз в минуту — после рестарта он восстановится с точностью до минуты, и для графиков этой точности хватает. А вот переменная, которую никто ни разу не отправлял — например, копившийся с прошлого месяца счёт до первой публикации, — у брокера не живёт: для неё нужно локальное хранение, и счётчик включений в EEPROM из соседнего раздела — готовый шаблон.
Про очередь команд при простое — вторая оговорка. Чистая сессия выбрасывает не только подписки: сообщения с QoS 1, отправленные в топик, пока плата была отключена, тоже не дождутся адресата — retained вернёт последнее значение каждого топика, но не всю очередь. Для реле света этого достаточно: важно конечное состояние, а не путь к нему. Если каждый транзит команды обязан доезжать — это уже задача постоянной сессии или брокерской логики поверх, и уровни доставки разобраны в уроке про QoS.
Тонкость, которую видно не сразу: retained годится для состояний, но не для команд с длительностью. «Насос на десять минут» в retained-топике после рестарта брокера и платы отработает заново с самого начала: таймер умер вместе с питанием, а retained-сообщение живо. Поэтому команды с горизонтом времени я храню не в топиках, а в уме контроллера: брокер получает только «насос: вкл», минуты считает плата, и после рестарта насос стартует заново осознанно — или не стартует, если правило дома другое.
Millis-таймеры после рестарта обнуляются — и это правильно: их смысл в интервале, а не в истории. Единственное, что стоит проверить после восстановления, — рассинхрон периодов: плата проснулась, а прошлая отсечка публикации осталась в прошлом цикле. Мой приём — первым делом после reconnect() публиковать снимок состояния: дашборд сразу видит, что узел жив, и время жизни статуса обнуляется.
- Отправьте led=1, затем led=0 и следите за светодиодом: каждое значение применяется немедленно — так выглядит retained-дамп после подписки.
- Перенесите client.subscribe() из setup() в начало loop() и убедитесь, что поведение не меняется, а шлюз потерялся: верните строку в reconnect() — правильное место одно.
- Отправьте в монитор hello: светодиод не дёрнется — белый список молча пропустил чужой payload.
- Добавьте второй топик home/hall/fan со своей переменной и своим восстановлением: у каждого топика своя пара, шлюз общий.
- Подписка вызывается там же, где connect: одна функция reconnect() — один шлюз для старта и обрывов
- PubSubClient не помнит подписок после разрыва: чистая сессия выбрасывает их на стороне брокера
- Retained-сообщения приходят сразу после subscribe — это и есть восстановление статуса
- Команда, отправленная во время простоя платы, доезжает только если топик публиковался с retain
- Переменные и millis-таймеры рестарт обнуляет честно: брокер помнит статусы, EEPROM — счётчики
- 1. Где должен жить client.subscribe(), чтобы подписка переживала обрывы?
- 2. Почему брокер забывает подписки клиента после disconnect?
- 3. Команду «вкл» отправили, пока плата была обесточена. Что увидит плата после рестарта, если топик публиковался с retain=true?
- 4. Какой топик НЕ стоит публиковать с retain?
- 5. Переменная счёта литров не публиковалась ни разу. Что с ней после рестарта?
Частые вопросы по теме урока
- Где должен жить client.subscribe(), чтобы подписка переживала обрывы?
- Любая дорога к подключению проходит через reconnect(): подписка рядом с connect выполняется и при старте, и после каждого обрыва. Внутри reconnect(), сразу после успешного connect
- Почему брокер забывает подписки клиента после disconnect?
- По умолчанию сессия чистая: на disconnect брокер выбрасывает подписки и очередь. Постоянные сессии существуют, но PubSubClient их не ведёт — надёжнее подписаться заново. Клиент подключился с чистой сессией: подписки живут только в сессии
- Команду «вкл» отправили, пока плата была обесточена. Что увидит плата после рестарта, если топик публиковался с retain=true?
- Брокер хранит последнее сообщение топика и отдаёт каждому новому подписчику немедленно. Истории команд он не ведёт — только последний снимок. Последнее значение топика сразу после подписки
- Какой топик НЕ стоит публиковать с retain?
- Retained-телеметрия с коротким периодом после простоя отдаёт полусутками давности — хуже честной пустоты. Статусы и команды, напротив, retain требуют. Температуру с периодом десять секунд
- Переменная счёта литров не публиковалась ни разу. Что с ней после рестарта?
- Брокер помнит только то, что публиковалось с retain. Непубликованные переменные восстанавливают локально — EEPROM на плате, как в уроке про счётчик включений. Умрёт вместе с RAM: брокер хранит только опубликованное