Датчик открытия калитки на ESP32 с двумя пальчиковыми батарейками жил четыре месяца. Я перевёл его на «универсальную» подписку # — и срок съёжился до пяти недель. Никто не взламывал дом: датчик теперь просыпался на каждое сообщение всех устройств, от термометра теплицы до счётчика воды, прогонял их через callback и снова засыпал.
Арифметика обидная: один термометр при периоде в десять секунд даёт 8640 сообщений в сутки — мы считали это в уроке про публикацию по millis. Умножь на десяток устройств — и батарейный датчик за неделю переваривает сотни тысяч чужих пакетов. Wildcard — инструмент мощный и ядовитый одновременно: сначала семантика, потом цена.
Вторая цена широкой подписки — не только заряд. Батарейный датчик слушал топики насоса и замка: чужой payload с текстом вместо цифры улетал в разбор и гасился проверками — но обработка всё равно шла, радиомодуль всё равно бодрствовал. Чем шире фильтр, тем больше чужих данных проходит через код, который писался для своих. Узкая подписка — это ещё и гигиена: в callback попадает только то, что ты готов разобрать, а мусор остаётся на брокере и не занимает твой loop.
Плюс: ровно один уровень
Символ + в фильтре занимает ровно один уровень топика. Уровни считаются по слэшам, без исключений: home/+/temp ловит home/kitchen/temp и home/attic/temp, но не home/kitchen/floor/temp — там между home и temp два уровня. Плюс можно ставить в любое место: +/temp поймает все термометры в корне, home/+ — всё, что лежит на втором уровне ветки home, например home/kitchen, но не её детей.
Две тонкости спецификации я проверил на живом брокере, а не в документации. Первая: + покрывает и пустой уровень — home/+/temp ловит даже home//temp с двойным слэшем. Вторая: плюс обязан занимать уровень целиком — фильтр home+kitchen/temp недопустим, брокер отвергнет подписку. И главное правило: wildcard живёт только в фильтрах подписки, а маски упоминались ещё в уроке про устройство протокола. Опубликовать сообщение в топик a/+/b нельзя — протокол запрещает wildcard у издателя.
Отсюда правило проектирования: иерархию топиков продумывают до первой прошивки. Мой дом живёт по схеме место/зона/устройство/метрика: dacha/greenhouse/heater/state. Ровная глубина делает маски предсказуемыми — один плюс занимает одну роль, и фильтр читается как шаблон: home/+/state — состояния всех устройств второго уровня. Плоские имена с подчёркиваниями вроде dacha_greenhouse_heater оставляют wildcard не у дел: слэшей нет — уровней нет — маскировать нечего.
#include <PubSubClient.h> WiFiClient net;PubSubClient client(net);const char* ID = "esp32-temp-logger"; void callback(char* topic, byte* payload, unsigned int length) { String t = String(topic); String p = ""; for (unsigned int i = 0; i < length; i++) { p += (char)payload[i]; } int s1 = t.indexOf("/"); int s2 = t.indexOf("/", s1 + 1); int s3 = t.indexOf("/", s2 + 1); if (s1 < 0 || s2 < 0 || s3 < 0) { return; } String room = t.substring(s2 + 1, s3); Serial.print("room="); Serial.print(room); Serial.print(" temp="); Serial.println(p);} void setup() { Serial.begin(9600); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer("192.168.1.50", 1883); while (!client.connect(ID)) { delay(2000); } client.subscribe("home/+/temp"); Serial.println("slushayu vse termometry vetki home");} void loop() { if (!client.connected()) { if (client.connect(ID)) { client.subscribe("home/+/temp"); } delay(3000); return; } client.loop();}| Фильтр | Ловит | Не ловит |
|---|---|---|
| home/+/temp | home/kitchen/temp, home/attic/temp | home/kitchen/floor/temp, dacha/temp |
| home/# | home, home/kitchen, home/kitchen/temp | dacha/greenhouse/temp |
| # | все обычные топики брокера | ветку $SYS со служебной статистикой |
| +/temp | home/temp, dacha/temp | home/kitchen/temp |
| home/ground/+/temp | home/ground/kitchen/temp | home/kitchen/temp, home/ground/temp |
| home/+ | home/kitchen | home, home/kitchen/temp |
Решётка # забирает все оставшиеся уровни разом. home/# — вся ветка home: и home/kitchen, и home/kitchen/floor/temp. Одна # без префикса — весь брокер: удобный фильтр для логгера и плохой для всего остального. Ограничений два: решётка обязана быть последним символом фильтра — home/#/temp брокер отвергнет на подписке, — и родительский уровень входит в матч: home/# ловит и сам топик home, даже без детей.
Служебные топики — отдельная история. Статистика mosquitto живёт в ветке $SYS: количество подключённых клиентов, память, очередь сообщений. Спецификация запрещает фильтрам, начинающимся с wildcard, матчить топики, начинающиеся с $: твоя # весь дом не увидит ни одного $SYS-сообщения. Смотреть статистику нужно явной подпиской $SYS/# — я вешаю её на ноутбук при настройке брокера, когда подозреваю, что кто-то переел трафик.
Числа для моего дома: двенадцать устройств, средний период публикации 20 секунд — выходит около 52 тысяч сообщений в сутки, и все они прилетели бы на одну решётку. Для Raspberry Pi это незаметно, для ESP8266 в гараже — заметная доля процессорного времени на чужой трафик, для батарейки — приговор. Отладочная решётка живёт у меня ровно в одном месте: mosquitto_sub -t # -v в терминале ноутбука, и то не на постоянку.
Фильтр сужает подписку, разбор — находит топик
Даже с маской callback получает полное имя топика — на этом строится экономия подписок. Схема, которую я применяю: одна подписка home/+/temp в setup и после каждого reconnect, а в callback — вырезание уровня комнаты через indexOf и substring. Десять термометров обслуживаются одной подпиской и одним обработчиком, код не дублируется, а неизвестный формат строки уходит в игнор без побочных эффектов.
QoS у маски договаривается на всю маску сразу: подписка home/+/temp с QoS 1 даёт уровень 1 каждому совпавшему топику, по одной комнате не настроить. Брокер держит список фильтров клиента и прогоняет матчинг для каждого входящего сообщения: у меня на Pi с двумя десятками устройств это миллисекунды, а слабый роутер с сотней клиентов уже чувствует широкие фильтры на себе — ещё один довод не рассылать # направо и налево.
Подписок у одного клиента может быть несколько — subscribe вызывают подряд, каждая возвращает свой результат. Но все они живут на стороне брокера и пропадают при чистой сессии: после каждого connect перечитываю весь список, иначе ловлю странность «часть данных приходит, часть молчит». А если два устройства встали под одно имя, они начнут выбивать друг друга из сети — механика войны имён разобрана в уроке про clientId и статусы.
У меня три уровня точности подписок. На плате-исполнителе — полный точный топик: свет, насос, замок. На сборщиках — маска одного уровня: логгер температуры подписан home/+/temp и не слышит команд. На отладочной машине — # и $SYS/#: там нужно всё, включая статистику брокера. Если плата батарейная, точность обязательна: каждое чужое сообщение — это пробуждение радио и расход миллиампер-часов.
И последняя привычка: прежде чем верить маске, гоняю пары фильтр-топик руками — ровно то, что делает симулятор ниже по тексту. Два неверных представления о плюс и решётке стоили мне одной ночи отладки: я был уверен, что home/# не ловит сам home, и полчаса искал, куда пропали сообщения. Проверка на парах занимает минуту, а между делами батарейную плату держу на millis-таймерах из урока про millis вместо delay — просыпаюсь по событию, а не по круговому опросу.
Чек-лист, по которому я выбираю фильтр перед каждой подпиской. Кому адресованы сообщения и кому они не адресованы? Батарейная ли плата и сколько сообщений в сутки она получит? Нужен ли уровень-идентификатор из топика, или хватит payload? Что произойдёт, если придёт чужое сообщение? Четыре честных ответа — и фильтр готов жить в прошивке без сюрпризов.
- + занимает ровно один уровень по слэшам: home/+/temp ловит даже home//temp, но не home/kitchen/floor/temp
- # — все оставшиеся уровни и последний символ фильтра; home/# ловит и сам home
- Wildcard живёт только в подписках: публиковать в маскиный топик запрещено протоколом
- $SYS-топики не матчатся wildcard-фильтрами — статистику брокера смотрят явной подпиской $SYS/#
- На исполнительных платах — точные топики; маски — для логгеров, шлюзов и отладки: каждый чужой пакет будит батарейку
- 1. Фильтр home/+/temp. Какой топик доставит брокер?
- 2. Что поймает фильтр home/#?
- 3. Можно ли опубликовать сообщение в топик a/+/b?
- 4. Увидит ли подписка # служебные топики $SYS/broker/…?
- 5. Почему широкая подписка на # убивает батарейный датчик?
Частые вопросы по теме урока
- Фильтр home/+/temp. Какой топик доставит брокер?
- Плюс покрывает ровно один уровень: home/attic/temp подходит. Вариант с floor/temp имеет два уровня между home и temp — плюс их не накроет, и брокер молча пропустит такое сообщение мимо подписки. home/attic/temp
- Что поймает фильтр home/#?
- Решётка забирает все оставшиеся уровни и включает родительский топик: home/# матчит и сам home, даже если у него нет ни одного ребёнка. Требование к решётке одно — быть последним символом фильтра. home, home/kitchen и всю ветку глубже
- Можно ли опубликовать сообщение в топик a/+/b?
- Маски — инструмент подписчика. Имя топика публикации всегда конкретное, иначе брокер отвергнет пакет ещё на проверке. Нет: wildcard допустим только в фильтрах подписки
- Увидит ли подписка # служебные топики $SYS/broker/…?
- Спецификация защищает служебную ветку: фильтры, начинающиеся с wildcard, не матчат топики, начинающиеся со $. Статистику смотрят явной подпиской $SYS/# — тоже с решёткой, но с якорем на служебный префикс. Нет: топики со $ не матчатся wildcard-фильтрами
- Почему широкая подписка на # убивает батарейный датчик?
- Брокер честно доставляет всё, что совпало с фильтром. Десяток устройств при периоде 10–30 секунд — это тысячи пробуждений в сутки и кратная потеря срока службы батареек: узкий фильтр дешевле любой оптимизации прошивки. Брокер шлёт батарейным клиентам дубли