Уличный датчик освещённости я вписал в дом за десять минут — а потом полвечера чинил дашборды, потому что топик уехал от схемы: street/lux вместо home/street/lux/state. Схема топиков живёт дольше любого скетча: код правится за минуту, а имена остаются в базе, в панелях, в автоматизациях и в моей памяти на годы.
К третьему году дома у меня сложилась схема из четырёх уровней: корень, зона, устройство, действие. Она отвечает на все вопросы, которые я задаю дому масками, и режется правами доступа ровно по границам уровней. Отдельной платой за неё стал опыт с решёткой: дашборд с подпиской home/# получал всё подряд — от телеметрии теплицы до команд шторам, и это я заметил не сразу.
Здесь разбираю роли уровней и словарь действий, цену решётки — не только для батарейки, но и для брокера — и тот честный случай, когда плоские имена лучше любой иерархии.
Четыре уровня: у каждого своя работа
Корень — граница дома. home перед первым слэшем отделяет ваши топики от служебных вроде $SYS и от чужих веток, если брокер один на несколько проектов. Вторая работа корня — версия схемы: когда доедете до большого переименования, ветка home2 даст переходный период, о котором ниже. Зона — где стоит устройство: kitchen, garage, street, greenhouse. Комнатой её называть узко: улица и теплица — не комнаты, а вопросы к дому задают одинаково.
Устройство — тип с номером экземпляра: thermo-1, light-2, curtain. Номер заводится с первого дня, даже когда устройство одно: второй экземпляр приходит внезапно, в момент, когда вы приносите из коробки второй датчик. Действие — последний уровень со словарём из четырёх слов: set — сюда пишут приказы, state — сюда устройство публикует факт, err — сбои и причины, avail — на связи или нет. Для датчиков действие заменяет метрика: home/street/lux/state или home/kitchen/thermo-1/temp — обе записи честные, главное, чтобы глубина была ровной у всех.
Плата за ровную глубина — предсказуемые маски: home/+/+/state читается как вопрос «факты всех исполнителей», home/+/+/temp — «все термометры». Правила имён внутри уровней — строчные буквы, дефисы, без ведущих слэшей — мы вырабатывали в уроке про структуру топиков, а семантику плюс и решётки — в уроке про wildcard-подписки; здесь же вопрос другой: что лежит на каждом уровне и зачем.
| Уровень | Роль | Словарь | Пример |
|---|---|---|---|
| Корень | граница дома и версия схемы | одно слово, обычно home | home |
| Зона | где стоит: комнаты и не только | короткие имена мест | garage, street |
| Устройство | тип плюс номер экземпляра | тип-номер | thermo-1, curtain |
| Действие | что происходит или что просят | set, state, err, avail | garage/curtain/set |
Словарь действий — единственная часть схемы, которую стоит проверять кодом. Функция публикации в скетче ниже принимает действие текстом и сверяет его со словарём: опечатка errr не рождает топик-призрак, а печатает строку в журнал. Топик собирается из констант по частям — ровно так, как лежит в схеме, — и никакого дублирования имени в трёх местах кода.
Второй довод проверять слова кодом: имя топика едет в каждом пакете, и посчитанная в уроке про публикацию по millis арифметика повторов имени в сутки работает в обе стороны — против длинных имён и против опечаток, которые дробят один ряд на два.
#include <WiFi.h>#include <PubSubClient.h> const char* WIFI_SSID = "HomeNet";const char* WIFI_PASS = "supernet123";const char* MQTT_SERVER = "192.168.1.50"; WiFiClient net;PubSubClient client(net); // Схема: home/<зона>/<устройство>/<действие>const char* ROOT = "home";const char* ZONES[3] = {"kitchen", "garage", "street"};const char* DEVICES[3] = {"thermo-1", "curtain", "lux-1"};const char* ACTIONS[2] = {"state", "err"}; // словарь действий unsigned long lastPub = 0;int zi = 0; void publishFact(const char* zone, const char* dev, const char* action, const char* value) { bool known = false; for (int i = 0; i < 2; i++) { if (strcmp(action, ACTIONS[i]) == 0) { known = true; } } if (!known) { Serial.print("[shema] dejstvie vne slovarya: "); Serial.println(action); return; // опечатка не рождает топик } String topic = String(ROOT) + "/" + zone + "/" + dev + "/" + action; client.publish(topic.c_str(), value); Serial.print("MQTT ↑ "); Serial.print(topic); Serial.print(" = "); Serial.println(value);} void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer(MQTT_SERVER, 1883); client.connect("esp32-shema-demo"); Serial.println("shema: home/<zona>/<ustroystvo>/<deystvie>");} void loop() { client.loop(); unsigned long now = millis(); if (now - lastPub >= 10000) { lastPub = now; publishFact(ZONES[zi], DEVICES[zi], "state", "23.5"); zi = zi + 1; if (zi > 2) { zi = 0; } }}Теперь цена решётки — не для батарейки, об этом был урок про wildcard-подписки, а для брокера и для всех вокруг. Каждая публикация сопоставляется со всей таблицей подписок; клиент с маской home/# получает копию каждого сообщения ветки. Восемь клиентов с решёткой — восемь копий всего домового трафика, и это трафик внутри одного роутера: процессор и память брокера на малине, пропускная способность Wi-Fi и время жизни батарейных узлов платят вместе.
Второй счёт приносит retain: брокер хранит последнее сообщение по каждому топику и раздаёт все накопленные значения каждому новому подписчику решётки при подключении. Дашборд, подписавшийся на home/# после полугода работы дома, получает разом несколько сотен retained-сообщений — и дальше тащит копию каждой публикации. Третий довод — гигиена кода: в callback исполнителя решётка приносит чужие команды, которые он не ждал; узкая маска оставляет мусор на брокере, мы об этом уже говорили.
И вот зачем схеме ровные уровни с ролями: под них режутся права. Пользователь curtain в ACL брокера получает write на home/garage/curtain/# и read на home/garage/curtain/set — одной строкой, без исключений; конфиг и грабли password_file разбирались в уроке про пароли и ACL. Схема, которая не режется на ветки, превращает права в список исключений — а список исключений в безопасности не работает.
Считать цену удобно по журналу, а не по ощущениям. mosquitto_sub -v у подписчика решётки печатает и топик, и payload: за ночь на моей малине счётчик строк уходил в десятки тысяч, из которых делу служили сотни — остальное была телеметрия, до которой дашборду нет дела. Встроенный брокер на малине такое переживает, а плата на ESP8266 с той же решёткой — нет: каждый чужой пакет будит Wi-Fi и процессор, и это уже цена из урока про wildcard. Ровная глубина и узкие маски — та же гигиена, что и словарь действий: расходы держат в цифрах, а не в ощущениях.
Когда плоские имена честнее
Квартира с тремя устройствами и одной панелью — это не система, а горстка розеток с мнениями. Плоские топики lamp-bedroom, temp-office и valve-hot живут годами без всякой иерархии: масок никто не задаёт, вопросов к дому нет — ветвить нечего. Плоский топик с JSON внутри — тот же API, разбор документа мы собирали в уроке про JSON-payload; разница только в том, где лежит структура.
На форумах за это спорят двумя лагерями: одни советуют ветку на каждого владельца и решётку для выборок, другие — плоские имена с префиксом, потому что их проще переименовывать и фильтровать на стороне клиента. Оба лагеря правы в своих условиях: ветка выигрывает, когда есть вопросы-маски и права по веткам; плоские имена — когда подписчиков один-два и фильтрация всё равно живёт в коде.
Моя граница применимости простая: появился второй подписчик, второй владелец или хоть одна строка в ACL — стройте дерево, с первого устройства и с ровной глубиной. До этого момента иерархия — это архитектура будущего в настоящем, где нечего переименовывать и некому задавать вопросы. Тестовый стенд на столе пусть живёт плоско: три топика с именами комнат в нём честнее любой схемы.
Три вопроса к дому перед каждой новой прошивкой. Первый: собирает ли одна маска всех исполнителей и все показания одного типа? Второй: отличает ли схема приказ от факта — set от state? Третий: можно ли одной строкой ACL вырезать новое устройство из дома? Если хоть один ответ «нет», дешевле поправить схему до прошивки, чем после.
Последний уровень — set — это дверь, в которую пишет человек: с телефона, из консоли брокера, одной строкой mosquitto_pub. А вместе с приказом человек хочет передать аргумент: яркость, минуты, номер. Разбор такого payload — глагол до пробела, число после — и есть тема следующего урока.
- Введите home/garage/curtain/set 1 и найдите в журнале строку про реле: точный топик дошёл до шторы, а решётка заплатила байтами за чужие темы.
- Включите тумблер телеметрии и сравните счётчики через минуту: у dash доставки растут на каждой публикации, у сторожа — только на state, у шторы не растут вовсе.
- Посчитайте по счётчику bayt цену улицы: публикация каждые 400 миллисекунд — это 216 тысяч сообщений в сутки, и столько же копий имён топиков уезжает в решётку.
- Введите home/kitchen/floor/temp/state — сторож home/+/+/state его не возьмёт: маска хочет ровно четыре уровня, и это не каприз брокера, а арифметика слэшей.
- Схема из четырёх уровней — корень, зона, устройство, действие: у каждого уровня одна роль и свой словарь
- Словарь действий set/state/err/avail проверяется кодом: опечатка не рождает топик-призрак
- Решётка стоит не только батарейке: каждая публикация даёт копию каждому подписчику маски, retained раздаётся при каждом подключении
- Плоские имена честны для трёх устройств без масок; второй подписчик или строка ACL — повод строить дерево с ровной глубиной
- Старый retained-топик снимается с хранения пустой публикацией с retain — иначе призрак живёт вечно
- 1. Что получит дашборд с подпиской home/# сразу после подключения к брокеру?
- 2. Почему ровная глубина схемы упрощает жизнь маскам?
- 3. Для какого набора устройств плоские имена — честный выбор?
- 4. Как снять retained-сообщение с хранения в брокере?
- 5. Что делает словарь действий в функции публикации?
Частые вопросы по теме урока
- Что получит дашборд с подпиской home/# сразу после подключения к брокеру?
- Брокер хранит по одному последнему сообщению на топик и раздаёт их каждому новому подписчику. Решётка собирает весь дом разом — несколько сотен сообщений после полугода работы. Копии всех накопленных retained-сообщений ветки разом
- Почему ровная глубина схемы упрощает жизнь маскам?
- При одинаковой глубине у каждого уровня есть роль: home/+/+/state — вопрос ко всем исполнителям. Разъехавшаяся глубина ломает и маску, и её смысл. Один плюс занимает одну роль, и фильтр читается как шаблон вопроса
- Для какого набора устройств плоские имена — честный выбор?
- Пока нет вопросов-масок, второго подписчика и прав по веткам, иерархия не приносит пользы. Порог входа в дерево — второй подписчик или первая строка ACL. Три устройства, одна панель, масками никто не пользуется
- Как снять retained-сообщение с хранения в брокере?
- Пустая публикация с retain=True стирает сохранённое сообщение. Без этого переименованный топик всплывает у каждого нового подписчика. Опубликовать пустой payload с флагом retain в тот же топик
- Что делает словарь действий в функции публикации?
- Проверка слова по словарю стоит две строки и защищает от топиков-призраков: errr не уедет в сеть и не расщепит ряд в дашборде. Отвергает действие вне set/state/err/avail: опечатка не создаёт топик