Лампа на веранде слушала дашборд исправно — до первой ночи с моргнувшим Wi-Fi. Утром кнопка «включить» нажималась, интерфейс услужливо мигал, а лампа молчала. Показания температуры при этом шли как ни в чём не бывало: публикация работала, разрыв соединения скетч пережил сам. Я перепрошил плату — помогло до следующего разрыва. Так я познакомился с подпиской, которая живёт только до первого reconnect.
Причина была в одной строке не в том месте. Subscribe я вызвал один раз в setup(), и брокер действительно подписал меня. Но у PubSubClient по умолчанию чистая сессия: при новом подключении брокер считает подписки прежней сессии несуществующими. В моём reconnect() соединение восстанавливалось, а подписка — нет: публикация шла, приём умирал. Перенёс subscribe внутрь reconnect(), сразу после успешного connect — и лампа перестала зависеть от погоды на роутере.
В этом уроке — обратный поток данных: как broker кладёт команду в плату, что происходит внутри callback и почему payload там не строка, а байты, которые надо собрать. Разберём разбор сообщений по топику, флаг-паттерн, спасающий от зависаний, и валидацию: сообщения с брокера приходят из внешнего мира, а внешнему миру я верю примерно как незнакомцу с ножницами у моего провода.
Обратный поток: от кнопки в дашборде до пина
Механика такая. client.subscribe(топик) отправляет брокеру запрос SUBSCRIBE, тот подтверждает и запоминает: любой PUBLISH в этот топик — и копия летит вашему клиенту. Библиотека принимает пакет, разбирает его и вызывает вашу функцию-callback с тремя аргументами: имя топика, массив байтов и длина. Регистрируется функция вызовом client.setCallback(callback) в setup — один раз, она живёт до конца работы. Важная развилка: маски из первого урока здесь работают, подписка dacha/pump/# накрывает и dacha/pump/on, и dacha/pump/status одним вызовом.
Топиков у подписки может быть несколько: второй, третий subscribe() просто добавляются рядом. А различать входящие сообщения нужно внутри callback по имени топика — обычным сравнением строк. Один callback обслуживает весь дом: пришло dacha/pump/on — щёлкаем реле, пришло dacha/led/brightness — крутим ШИМ. Строковые сравнения и сборка сообщений — это те же приёмы из уроков про String и Serial: здесь ничего нового, просто источник другой.
Payload — главный сюрприз для новичка. В callback он приходит как массив байтов, а не как строка с нулём на конце: String(topic) — потому что топик нуль-терминирован, а вот сообщение собираем в цикле по length, байт за байтом. Пропустить length и сравнить массив со строкой — классическая ошибка, которая компилируется и работает ровно до первого сообщения с хвостом мусора.
void callback(char* topic, byte* payload, unsigned int length) { String msg = ""; for (unsigned int i = 0; i < length; i++) { msg += (char)payload[i]; // байты -> строка, строго по length } String t = String(topic); if (t == "dacha/pump/on") { if (msg == "1") { digitalWrite(RELE, HIGH); } if (msg == "0") { digitalWrite(RELE, LOW); } } else if (t == "dacha/led/brightness") { int level = msg.toInt(); // мусор даст 0 — не страшно if (level > 255) { level = 255; } analogWrite(LED_PIN, level); } Serial.print(t); Serial.print(" -> "); Serial.println(msg);} // в setup(): client.setCallback(callback);// в reconnect(): client.connect("dacha-pump-1");// client.subscribe("dacha/pump/on"); // КАЖДЫЙ раз// client.subscribe("dacha/led/brightness");Теперь про то, что callback — это чужая территория. Вашу функцию вызывает client.loop() посреди разбора входящих пакетов. Внутри callback нельзя публиковать через client.publish — библиотека может заблокироваться, ведь она ещё не закончила приём прошлого пакета. Нельзя и тянуть время: delay, длинные циклы, обращение к медленной периферии. Моё правило: callback только фиксирует факт — записывает значение во флаг или переменную, — а вся работа происходит в loop(). Сто миллисекунд работы внутри callback — это сто миллисекунд, когда соединение не обслуживается; при потоке сообщений легко словить каскад.
Флаг-паттерн выглядит скучно и работает безотказно: volatile-переменная, в callback присвоили, в loop проверили и исполнили. Для реле из примера можно и прямо в callback — digitalWrite занимает микросекунды, и я допускаю такое для одиночных быстрых действий. Граница: всё, что дольше пары миллисекунд или вызывает publish — только через флаг. Если в проекте есть планировщик задач на millis, обработка команды становится просто задачей в очереди — и callback вырождается до одной строки.
И вернёмся к подписке из-за которой лампа молчала. Правило звучит так: каждый вызов connect() обнуляет подписки, значит каждый успешный connect завершается блоком subscribe. В reconnect() из прошлого урока это уже встроено — теперь вы знаете, почему там клиент подписывается на dacha/pump/on именно после connect, а не где-то в setup.
Разбор payload по топику: что пришло и можно ли верить
| Топик | Ждём в payload | Действие | Проверка |
|---|---|---|---|
| dacha/pump/on | 1 или 0 | щелчок реле насоса | всё остальное игнорируем молча |
| dacha/led/brightness | 0–255 | analogWrite на ШИМ-пин | toInt плюс ограничение сверху |
| dacha/thermo/request | любой непустой | опубликовать текущую температуру | действие только чтение, риска нет |
| dacha/pump/status | текст | печать в Serial-журнал | только лог, на исполнитель не влияет |
Последний столбец таблицы — самое важное. Сообщение с брокера — данные из внешнего мира, с тем же статусом, что ввод с клавиатуры или строка из Serial. Никаких переходов модуля реле от произвольного текста: если ждём 1 или 0, всё остальное игнорируем. toInt() при мусоре вернёт ноль — для яркости это просто выключенный свет, но тот же ноль для насоса может значить «слей воду» — поэтому для исполнительных механизмов я сравниваю payload со строками явно, а не конвертирую наивно.
История про это: коллега тестировал свой скетч и публиковал единицы в общий тестовый топик — а мой скетч, подписанный на dacha/# ради отладки, услышал его единицу и погасил свет в доме. С тех пор у меня два уровня защиты: команды для исполнительных механизмов живут в отдельных топиках без масок, и в callback стоит белый список: три известных топика обрабатываются, всё остальное молча летит в лог. Отладочные подписки я всегда помечаю в коде комментарием, чтобы при переносе в продакшн не забыть их вычистить.
Модель, на которой я отлаживаю весь этот конвейер без железа, — ниже. Монитор порта играет роль брокера — приём сообщений по строке тут работает как в командном парсере из Serial: вы печатаете сообщение вида топик пробел payload, а скетч разбирает его ровно так, как это делает настоящий callback: выделяет топик, выделяет payload, прогоняет через белый список и щёлкает реле. Плюс к тому же видно, как ведёт себя скетч при чужом топике и мусорном payload.
- Отправьте dacha/pump/on 1, затем dacha/pump/on 0, затем dacha/pump/on 7: первые две команды щёлкают реле, третья уходит в IGNOR. Это и есть белый список payload вживую.
- Попробуйте команду без пробела, например dachapumpon1: скетч ответит про формат. В настоящем callback такая строка пришла бы в один топик без payload — и тоже ушла бы в игнор.
- Добавьте ветку для dacha/led/brightness: превратите payload в число через toInt, ограничьте 0–255 и напечатайте level=. Реле не трогайте — яркость и насос не должны зависеть от одного топика.
- Верните в головы reconnect-логику: допишите в комментарий скетча, какие два вызова должны следовать за каждым успешным connect, и сверьтесь с кодом из урока. Проверка себя: почему subscribe не выживает reconnect при чистой сессии?
- subscribe() живёт на стороне брокера, но при чистой сессии обнуляется — подписка восстанавливается после каждого успешного connect, внутри reconnect()
- setCallback регистрирует функцию с тремя аргументами: топик, байты, длина; payload собирается в String по length, не по нулевому байту
- В callback запрещены publish, delay и долгая работа: только флаги и переменные, исполнение — в loop()
- Разбор входящих — по имени топика: if по строке, у одного callback сколько угодно топиков, маски # и + работают в subscribe
- Payload — данные из внешнего мира: белый список значений и топиков, toInt только там, где ноль безвреден
- 1. Лампа перестала реагировать на команды после каждого ночного разрыва Wi-Fi, публикация работает. Что проверить первым?
- 2. Что приходит в callback третьим смыслом параметра length?
- 3. Почему client.publish() внутри callback опасен?
- 4. Payload в топик команды пришёл равным off вместо 0. Что сделает скетч с явным сравнением if (msg == 1) / if (msg == 0)?
- 5. Одному ESP32 нужно обслуживать насос и яркость ленты. Как правильно?
Частые вопросы по теме урока
- Лампа перестала реагировать на команды после каждого ночного разрыва Wi-Fi, публикация работает. Что проверить первым?
- При чистой сессии брокер теряет подписки клиента при переподключении. Subscribe должен стоять в reconnect() сразу после connect, а не один раз в setup. Вызывается ли client.subscribe() после каждого успешного connect
- Что приходит в callback третьим смыслом параметра length?
- payload не нуль-терминирован: собрать строку корректно можно только циклом по length. Хвост массива за length — не ваши данные. Число байтов, которые нужно прочитать из payload
- Почему client.publish() внутри callback опасен?
- Callback исполняется внутри обслуживания соединения. Вложенная отправка ломает конечный автомат библиотеки; правильный путь — флаг в callback, publish в loop. Callback вызывается из client.loop() во время разбора входящего пакета — библиотека может заблокироваться
- Payload в топик команды пришёл равным off вместо 0. Что сделает скетч с явным сравнением if (msg == 1) / if (msg == 0)?
- Явное сравнение с ожидаемыми значениями — это белый список: неизвестный payload игнорируется. Именно так команды переживают чужие и сломанные сообщения. Ничего: строка не совпала ни с чем из белого списка
- Одному ESP32 нужно обслуживать насос и яркость ленты. Как правильно?
- Callback один, а ветвление — по имени топика. Подписок может быть несколько; маска dacha/# удобна для отладки, для команд исполнительным механизмам лучше отдельные топики без масок. Один callback: внутри сравнение топика строкой, каждый топик подписан отдельным subscribe()