Контроллер в коридоре подписан на три топика: свет, вентилятор и общий канал «все нагрузки». Через день после запуска я включил вентилятор — и вместе с ним загорелся свет в коридоре. Никакой мистики: в PubSubClient колбек один на клиента, и брокер ссыпает в него всё, на что вы подписаны. Библиотека не разбирает, кому предназначено сообщение, — она честно протягивает вам топик, а сортировать приходится самим. Первые недели все пишут if (payload == «on») в единственном колбеке, а потом добавляют вторую подписку и ловят перекрёстные срабатывания.
Как назначить функцию через setCallback и принять первую команду, показано в уроке про первый callback — там минимальный скетч с одной веткой. Здесь идём дальше: разберём, что именно библиотека кладёт в аргументы, почему payload нельзя печатать напрямую, и как превратить строку топика в понятный диспетчер с ветками для светодиода и реле. Заодно решим, где начинать и заканчивать проверку startsWith, чтобы home/light/cmd не поймал чужое home/lightcmd.
Сигнатура: что библиотека кладёт в аргументы
Обработчик описывается так: void callback(char* topic, byte* payload, unsigned int length). Три аргумента ведут себя по-разному, и это первая развилка понимания. Topic — обычная C-строка: библиотека кладёт туда нулевой байт, поэтому topic можно печатать в Serial и передавать в String(topic) без болячек. Payload — не строка. Это голые байты без завершающего нуля, а длина едет отдельным аргументом length. Правило, которое экономит вечер: всё, что длиннее topic, сначала копируйте в свой буфер, обрежьте по length и допишите нулевой байт — только тогда сравнения и печать становятся безопасными.
Почему одна функция на все подписки — не лень автора библиотеки, а устройство протокола: MQTT не знает вашей семантики, брокер доставляет поток сообщений, и только вы знаете, что значит home/light/cmd для вашей платы. Отсюда и стиль: колбек — узкая дверь, внутри которой стоит охранник с маршрутной таблицей. Как устроены сами адреса и зачем иерархия home/этаж/назначение, раскладывалось в уроке про структуру топиков — здесь считаем, что имена уже придуманы.
Аргумент length — недооценённая штука. Он честно сообщает, сколько байтов в сообщении, и позволяет обойтись без strlen над голыми байтами: копируйте сразу length, а не «до нуля». Вторая польза — дешёвая отсечка мусора: у командной ветки с тремя значениями on/off/lock payload длиннее пяти байтов уже подозрителен, и проверка длины до разбора экономит и циклы, и нервы. Третья: length нужен при копировании в буфер фиксированного размера — сравните его с ёмкостью буфера минус один и обрежьте до копирования, иначе memcpy с чужим length промажет мимо стека, и плата перезагрузится молча, без единой строки в мониторе.
#include <WiFi.h>#include <PubSubClient.h> const char* WIFI_SSID = "HomeNet";const char* WIFI_PASS = "12345678";const char* BROKER = "192.168.1.50"; const int LED_LIGHT = 12; // ветка home/light/cmdconst int RELAY_FAN = 7; // ветка home/fan/cmd WiFiClient net;PubSubClient client(net); int vetka(String t) { // числовой диспетчер: switch ест только int if (t.startsWith("home/light/cmd")) return 1; if (t.startsWith("home/fan/cmd")) return 2; if (t.startsWith("home/all/cmd")) return 3; return 0;} void callback(char* topic, byte* payload, unsigned int length) { char buf[16]; // payload без нулевого байта - копируем сами unsigned int n = length; if (n > sizeof(buf) - 1) n = sizeof(buf) - 1; memcpy(buf, payload, n); buf[n] = 0; // теперь это честная строка String t = String(topic); Serial.print("-> "); Serial.print(topic); Serial.print(" = "); Serial.println(buf); switch (vetka(t)) { case 1: if (String(buf) == "on") digitalWrite(LED_LIGHT, HIGH); if (String(buf) == "off") digitalWrite(LED_LIGHT, LOW); break; case 2: if (String(buf) == "on") digitalWrite(RELAY_FAN, HIGH); if (String(buf) == "off") digitalWrite(RELAY_FAN, LOW); break; case 3: // общий канал: обе нагрузки разом if (String(buf) == "on") { digitalWrite(LED_LIGHT, HIGH); digitalWrite(RELAY_FAN, HIGH); } if (String(buf) == "off") { digitalWrite(LED_LIGHT, LOW); digitalWrite(RELAY_FAN, LOW); } break; default: Serial.println("[?] топик мимо маршрутов - игнорирую"); break; }} void setup() { Serial.begin(115200); pinMode(LED_LIGHT, OUTPUT); pinMode(RELAY_FAN, OUTPUT); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) delay(300); client.setServer(BROKER, 1883); client.setCallback(callback);} void loop() { if (!client.connected()) { if (client.connect("controller-01")) { client.subscribe("home/light/cmd"); client.subscribe("home/fan/cmd"); client.subscribe("home/all/cmd"); } } client.loop();}Разбор строки: startsWith, indexOf и switch
Порядок проверок — половина маршрутизации. Идите от частного к общему: сначала home/light/cmd, потом широкие ветки вроде home/light/. Если начать с широкой, узкие ветки до своих проверок не доживут: startsWith съест их первой. Второй инструмент — indexOf: когда ветки различаются сегментом в середине или вам нужно вытащить имя комнаты, ищите слэши и режьте substring-ом. Границы важны до байта: home/lightcmd не равен home/light/cmd, но startsWith("home/light") второй поймает по глупости — оставляйте слэш в шаблоне проверки.
На живом примере граница видна лучше. Схема home/этаж/комната/назначение даёт топики вида home/1/kitchen/light/cmd, и наивный startsWith("home/1") проглотит и свет, и розетки, и датчики этажа. Мой порядок такой: сначала самые длинные шаблоны с командой на конце, потом ветки среднего уровня вроде .../light/, и в самом конце глобальные каналы. Если уровней много, вместо лестницы проверок вырезаю назначение сегментом и сравниваю его со словарём — тот же числовой диспетчер, только номер берётся из середины строки. Групповые каналы и раскладку ролей между общими и личными топиками я подбирал в уроке про групповые команды — там же о том, почему общий канал стоит держать на отдельном уровне, а не в корне.
Switch по строке в C++ не работает — компилятор просит целое. Отсюда мой приём из скетча выше: маленькая функция vetka() переводит топик в номер, и switch получает читаемые case 1/2/3 вместо лестницы if-ов. Заодно диспетчер легко тестировать: вызвать vetka("home/fan/cmd") из любого места скетча и посмотреть, что вернёт, не трогая железо.
Про String честно: на Uno с двумя килобайтами RAM каждая String-операция — это фрагментация кучи, и на длинных строках плата перезагружается без объяснений. Для трёх-четырёх веток с короткими топиками я String-ом пользуюсь без угрызений: читаемость дороже. На ESP32 с сотнями килобайт вопрос исчезает. Но если веток становится десять и больше, перехожу на char-буферы и strncmp — это единственный случай, когда код уродливее, зато предсказуемее. К слову, превращать topic в String не стыдно: стыдно делать это на каждый байт в цикле — один раз в начале колбека и хватит.
| Подход маршрутизации | Где уместен | Грабля |
|---|---|---|
| Цепочка startsWith от частного к общему | 3-7 веток с фиксированными именами | широкий шаблон без слэша ловит чужие топики |
| indexOf по слэшам + substring сегментов | нужно имя узла или комнаты из топика | сегменты переменной длины: индексы считайте заново для каждого сообщения |
| Числовой диспетчер vetka() + switch | когда веток много и хочется читаемых case | номера и топики расходятся после правок: проверяйте пары тестом |
| Код команды в payload (topic один, команды разные) | общий канал управления на несколько нагрузок | теряется иерархия: не выйдет подписаться на одну нагрузку через # |
Пара слов о том, что маршрутизация начинается не в колбеке, а в subscribe. Подписка шире разбора — типовой промах: клиент подписан на home/#, а ветки в диспетчере описаны только для команд. В колбек начнут падать чужие статусы соседних узлов, и рано или поздно один из них совпадёт с командной веткой. Дисциплина простая: подписывайтесь на то, что умеете разобрать, а про цену и поведение вайлдкартов плюс и решётка подробно разобраны в уроке про wildcard. И не забывайте client.loop() в цикле: без него колбек не вызовется ни для какого топика, какой бы правильный ни был диспетчер.
Отдельно про место колбека в цикле. Обработчик вызывается из client.loop(), а значит, всё, что в нём написано, тормозит приём следующих пакетов: digitalWrite пролетит незаметно, а вот delay(500) прямо в ветке задержит и остальные топики, и keepalive. Если ветка тяжёлая — пересобрать сообщение, записать в EEPROM, — запишите команду во флажок и обработайте в основном цикле. Планирование задач на millis вместо delay и разбор цикла, где loop() клиента и ваши дела уживаются без блокировок, показаны в уроке про millis вместо delay.
Диспетчер, который не рассыпается при росте, начинается с тестов, а не с кода. У меня привычка: прежде чем добавлять ветку, я добавляю в setup() три строки с проверкой vetka() на знакомых топиках и печатью результата — две секунды в мониторе, и я знаю, что новая проверка не перехватывает соседние. Дальше — правило одной обязанности: колбек только разбирает и запоминает, действие делает основной цикл. Разница видна на общем канале «все нагрузки»: если прямо в колбеке щёлкать реле и мигать светодиодом, ветка станет местом, где спрятана половина логики, и через месяц вы будете бояться её трогать.
И второе, к чему я пришёл дорогой потерь: имена веток — это интерфейс, а интерфейс меняется по договорённости. Как только командный топик начинает слушать больше одного устройства, переименование уровня в середине иерархии ломает всех сразу. Пишите константы T_LIGHT_CMD вместо строковых литералов по коду — в скетче выше я оставил литералы для краткости, но в своём проекте вынесите их наверх. Тогда вся маршрутизация видна на одном экране, а новый участник команды узнаёт карту топиков, не читая колбек.
- 1. Сколько функций-обработчиков можно назначить одному клиенту PubSubClient через setCallback?
- 2. Что нужно помнить про аргумент payload в колбеке?
- 3. Почему проверки startsWith пишут от частного шаблона к общему?
- 4. switch (topic), где topic — String, приведёт к…
- 5. Клиент подписан на home/#, а в диспетчере разобраны только командные ветки. Что плохого?
- Колбек один на клиента: брокер ссыпает туда все подписки, сортирует ваш диспетчер внутри
- topic — C-строка с нулём на конце; payload — байты без нуля, length отдельно: копируйте, обрезайте, завершайте
- Порядок startsWith — от частного к общему, слэш оставляйте в шаблоне, чтобы не поймать свет вместо lightcmd
- switch не ест строки: vetka() переводит топик в номер, case-ы читаются и тестируются по одному
- Маршрутизация начинается в subscribe: подписка шире разбора рано или поздно ловит чужой статус
Частые вопросы по теме урока
- Сколько функций-обработчиков можно назначить одному клиенту PubSubClient через setCallback?
- setCallback назначает единственный обработчик на клиента, и новый вызов затирает предыдущий. Разделение по топикам делаете вы сами внутри функции, разбирая строку topic. Одну: обработчик общий для всех подписок клиента
- Что нужно помнить про аргумент payload в колбеке?
- Библиотека передаёт указатель на байты и их количество, а не строку. Печать и сравнение безопасны только после копирования в буфер и дописывания нулевого байта по length. Это байты без нулевого байта, длина живёт в length — копируйте и завершайте сами
- Почему проверки startsWith пишут от частного шаблона к общему?
- Первая подошедшая проверка завершает разбор. Если startsWith("home/light/") стоит раньше startsWith("home/light/cmd/on"), до узкой ветки дело не дойдёт никогда. Так код быстрее компилируется
- switch (topic), где topic — String, приведёт к…
- Метка case в C++ обязана быть целой константой. Для строковых топиков делают функцию-диспетчер, возвращающую номер ветки, и уже по ней строят switch. Отказу компиляции: switch в C++ не работает со строками
- Клиент подписан на home/#, а в диспетчере разобраны только командные ветки. Что плохого?
- Брокер доставляет всё, что совпало с шаблоном. Ветку диспетчера должен иметь каждый топик, который реально может прийти, — либо подписку сужают до разобранных веток. В колбек упадут чужие статусы, и один может совпасть с командной веткой