Свет в прихожей включался и через полсекунды гас обратно — второй вечер подряд, и я уже грел паяльник, подозревая реле. Виноват оказался не провод, а мои же подписки: плату слушали home/hall/light/cmd и home/+/cmd — второй фильтр я повесил «чтобы видеть всё, что летит в прихожую, для отладки». Брокер честно доставил команду по обеим подпискам, callback отработал дважды, а ветка света у меня была переключателем: раз включила, второй раз выключила. Полсекунды — время между двумя копиями сообщения.
Механика у подписок простая, но слоёв хватает: вызовов subscribe может быть сколько угодно, а callback у клиента ровно один — структуру обработчика и приём payload мы проходили в уроке про первый callback, а startsWith-диспетчер на три ветки собран в уроке про маршрутизацию. Здесь добавляю то, чего в тех уроках нет: strcmp по топику без String, приоритет конкретных веток над общим каналом и цена перекрытия фильтров, которую я оплатил мигающим светом.
План: собрать три подписки — свет, вентилятор, общий канал — на strcmp, разобрать, когда вместо них выгоден wildcard, и похоронить двойную доставку дедупликацией. Код полный, от Wi-Fi до loop.
Три подписки: strcmp по топику
Каждый вызов client.subscribe() — отдельная строка в реестре брокера: home/light/cmd, home/fan/cmd, home/all/cmd — три вызова, три записи. Брокер держит их в рамках сессии: с чистой сессией разрыв соединения уносит реестр вместе с ней, и после каждого reconnect скетч обязан подписаться заново. Я собираю это в функцию subscribeAll() и зову после каждого успешного connect — один раз в setup и внутри reconnect-логики. Забытая переподписка — классика грабель: плата «работает», публикует, а команды не получает; видно это только в логе брокера, где при connect пришли ноль SUBSCRIBE-запросов. Три строки вызовов читаются быстрее цикла по массиву имён, но при десятке топиков я перехожу на массив — и subscribeAll() остаётся тем же местом правды.
Топик приходит в callback C-строкой с нулевым байтом, и сравнивать её честнее strcmp: функция идёт по байтам до первого несовпадения, ничего не выделяет и не копирует. String(topic) на ESP8266 прожил бы без последствий, но на Uno каждая такая строка — кусок в двухкилобайтной куче, а callback срабатывает на каждое сообщение: сотня String в час — и куча фрагментируется так, что даже буферу MQTT развернуться негде. Привычка strcmp стоит ноль усилий и переносится на все платы без изменений; для сравнения важен только ноль в возврате — полное совпадение, любые другие значения различаются лишь знаком. Белый список значений для команды — тот же приём, что в уроке про командный парсер Serial.
Приоритет обработки — правило одной строки: конкретные ветки раньше общего канала, разобрал — и return. Свет прилетел: обработал и вышел; вентилятор — то же; общий all/cmd выполняется последним, потому что он по определению грубее — выключить всё, что есть. Порядок if-ов — не стилистика, а механика приоритета: strcmp идёт сверху вниз, конкретная ветка найдётся раньше групповой, и return не даст общему каналу переиграть локальную команду. Тяжёлую работу — digitalWrite с публикациями отчётов, таймеры — callback не делает вовсе: ставит флаг, исполняет loop. Паттерн «команда из callback, исполнение в loop» целиком разобран в уроке про групповые команды, ниже он в коде.
#include <ESP8266WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const char* WIFI_SSID = "HomeNet";const char* WIFI_PASS = "12345678"; const int PIN_LIGHT = 12; // ветка home/light/cmdconst int PIN_FAN = 7; // ветка home/fan/cmdint cmdLight = -1; // -1 = команды нет, исполнение в loopint cmdFan = -1; void callback(char* topic, byte* payload, unsigned int length) { char msg[12]; // payload без нулевого байта - копируем сами unsigned int n = length; if (n > sizeof(msg) - 1) { n = sizeof(msg) - 1; } memcpy(msg, payload, n); msg[n] = 0; // конкретные ветки раньше общего, разобрал и вышел if (strcmp(topic, "home/light/cmd") == 0) { if (strcmp(msg, "on") == 0) { cmdLight = 1; } if (strcmp(msg, "off") == 0) { cmdLight = 0; } return; } if (strcmp(topic, "home/fan/cmd") == 0) { if (strcmp(msg, "on") == 0) { cmdFan = 1; } if (strcmp(msg, "off") == 0) { cmdFan = 0; } return; } if (strcmp(topic, "home/all/cmd") == 0) { if (strcmp(msg, "off") == 0) { cmdLight = 0; cmdFan = 0; } }} void subscribeAll() { // зову после КАЖДОГО успешного connect client.subscribe("home/light/cmd"); client.subscribe("home/fan/cmd"); client.subscribe("home/all/cmd");} void setup() { Serial.begin(115200); pinMode(PIN_LIGHT, OUTPUT); pinMode(PIN_FAN, OUTPUT); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer("192.168.1.50", 1883); if (client.connect("prizhaya-esp")) { subscribeAll(); }} void loop() { client.loop(); if (!client.connected()) { if (client.connect("prizhaya-esp")) { subscribeAll(); } return; } if (cmdLight >= 0) { digitalWrite(PIN_LIGHT, cmdLight ? HIGH : LOW); cmdLight = -1; } if (cmdFan >= 0) { digitalWrite(PIN_FAN, cmdFan ? HIGH : LOW); cmdFan = -1; }}Общий канал я осознанно сделал выключателем: off в all/cmd гасит всё, on — редкий жест, который я оставляю только автоматизации «уходя, выключи всё». Включение всего подряд из группового топика рано или поздно удивляет: дёрнула all по расписанию — а в детской спит ребёнок. Белый список payload здесь жёстче, чем где-либо: только ровно on и ровно off, всё прочее молча игнорируется — групповой канал слушают несколько плат, и одна мусорная строка размножилась бы на все.
Флаги cmdLight и cmdFan объявлены с минус единицей — это «команды нет». Ноль тут легитимная команда «выключить», поэтому минус единица: callback меняет флаг на единицу или ноль, loop исполняет и возвращает минус единицу обратно. Так callback остаётся коротким даже при пяти подписках, а в loop всё исполнение видно глазами: одна строка — одно действие.
Подписок больше — проверок больше, и callback растёт линейно. Моё правило: каждая ветка — не длиннее шести строк: белый список payload, два if, флаг. Всё сложнее — например, парсер аргументов яркости — живёт в loop рядом с исполнением, а callback только ставит флаг «пришёл аргумент». Callback, который тянет разбор внутрь себя, рано или поздно задержит client.loop() на чужом топике, и очередь брокера начнёт копиться: я попадал этим в связку с уроком про раздачу копилки — там длинный callback тормозил раздачу догоняющих точек. Глубину иерархии и имена веток я закладываю заранее: ровная глубина делает и strcmp-ветки, и маски предсказуемыми.
Wildcard против россыпи подписок
Три подписки — не догма, а выбор по семантике. Нагрузки разные — свет, вентилятор, общая группа — и реагируют они по-своему: отдельные топики честнее одного фильтра с разбором уровней. А вот восемь термометров по комнатам — случай, где восемь подписок проигрывают одной home/+/temp: топики однотипные, разница только в уровне, и маска читается как шаблон. Семантику плюса и решётки — ровно один уровень против всей ветки, пустые уровни, запрет wildcard у издателя — я разбирал в уроке про wildcard, здесь только выбор.
Цена у подходов разная. Три подписки — три пакета SUBSCRIBE при каждом connect и три записи в брокерском реестре: пакет подписки весит десятки байтов, и три таких — копейки; реальная цена широкого фильтра не в подписке, а в потоке, который она пропускает. На плате от розетки это ноль, на батарейном узле каждое чужое сообщение будит радиомодуль и жжёт микроамперы — арифметика восьми тысяч сообщений в сутки посчитана там же, в уроке про маски. Мой практический потолок: до трёх-четырёх разнородных веток — отдельные подписки, россыпь однотипных — плюс.
И правило, за которое я заплатил светом в прихожей: на одном клиенте не смешивать конкретные подписки с wildcard, который их же покрывает. Нужно и работать, и подглядывать — заводите двух клиентов: рабочий узел слушает свои топики, монитор с решёткой живёт отдельной прошивкой на малине. Перекрытие фильтров брокер разрешает спокойно, а расплачивается ваша toggle-ветка.
| Ситуация | Как подписан | Почему так |
|---|---|---|
| Свет, вентилятор, общий канал | три отдельных subscribe | семантики разные; каждая ветка короткая, конкретное раньше общего |
| Восемь термометров по комнатам | home/+/temp | топики однотипны; одна подписка вместо восьми, комнату беру из топика |
| Отладка: видеть всё | home/# на отдельном клиенте | монитор не мешает рабочим подпискам узла, двойной доставки нет |
| Батарейный датчик | только узкие топики | чужой трафик будит радиомодуль; широта фильтра ест батарейку |
Дедупликация из модели — три переменные и одно сравнение: последний топик, последняя команда и метка millis; копия в пределах трёхсот миллисекунд глушится. Окно нужно, потому что легитимные повторы команды бывают сами: человек дважды нажал «включить» — два разных намерения, а не дубль доставки. Триста миллисекунд — компромисс: копии брокера приходят подряд, за десятки миллисекунд, а живые повторные нажатия живут дольше. Мнение: дедуп ставлю только на командных ветках с toggle; телеметрии он не нужен вовсе.
Второй урок из модели — дисциплина loop: в модели callback дёргает digitalWrite мгновенно, на реальной плате исполнение у меня всегда в loop по флагам. Паузы между попытками reconnect тоже живут на millis, а не на delay — иначе loop замораживается на свои три секунды и входящие в это время теряются; схема с паузами — в уроке про millis-бэкофф.
- 1. Брокер получил home/light/cmd. Клиент подписан и на home/light/cmd, и на home/+/cmd. Сколько копий придёт?
- 2. Почему в callback я сравниваю топик через strcmp, а не String?
- 3. Что обязан делать скетч с подписками после каждого reconnect?
- 4. Когда три отдельные подписки лучше одного home/+/cmd?
- 5. Где исполняется команда, чтобы callback оставался коротким?
- Подписки ставятся после каждого connect: чистая сессия забывает их вместе с разрывом — держите subscribeAll().
- Callback один на клиента: strcmp по топику, конкретные ветки раньше общего, разобрал — и return.
- strcmp вместо String: без выделений в куче и фрагментации, одинаково на Uno и ESP.
- Перекрывшиеся фильтры — двойная доставка: toggle-ветка отработает дважды и вернёт всё назад; дедуп — пара целых и millis().
- Wildcard — для россыпи однотипных топиков; разнородным нагрузкам — отдельные подписки, решётку — отдельному клиенту-монитору.
Частые вопросы по теме урока
- Брокер получил home/light/cmd. Клиент подписан и на home/light/cmd, и на home/+/cmd. Сколько копий придёт?
- Спецификация разрешает отсылать по каждой совпавшей подписке, и mosquitto так делает: callback отработает дважды. Две: по каждой совпавшей подписке
- Почему в callback я сравниваю топик через strcmp, а не String?
- String на каждое сообщение — куски в куче и фрагментация; strcmp копирует ничего и работает одинаково на всех платах. strcmp идёт до первого несовпадения и не трогает кучу
- Что обязан делать скетч с подписками после каждого reconnect?
- Реестр подписок живёт в рамках сессии: разрыв унёс его, и после каждого успешного connect подписки ставятся заново. Переподписываться: чистая сессия забыла старые
- Когда три отдельные подписки лучше одного home/+/cmd?
- Разным нагрузкам — разные ветки и свой приоритет обработки; маска выигрывает на россыпях однотипных топиков. Когда нагрузки разные по семантике и их немного
- Где исполняется команда, чтобы callback оставался коротким?
- Callback ставит флаг и выходит; loop исполняет и возвращает флаг в «команды нет». Так callback короткий при любом числе подписок. В loop по флагам из callback