Ко мне в квартиру MQTT пришёл как обычно приходит: первым свет — топик light1. Через месяц добавился t_kuhnya, потом kuhnya_temp_new, потом second_rele_kuhnya_okno. Панель знала каждое имя наизусть, wildcards были бесполезны — у топиков нет общей структуры, — а добавление пятого датчика означало правку панели, правку лога и десять минут гадания, что же публикует second_rele_kuhnya_okno: состояние или команду. Число устройств перевалило за десяток, и я потратил вечер на переезд.
Переезд занял один вечер, потому что схема простая: топик — путь в дереве, уровни через слэш. home/kitchen/temp — температура кухни, home/bedroom/light/set — команда свету спальни. Первое слово — корень проекта, второе — комната, третье — сущность, четвёртое — канал: телеметрия живёт без канала, команды получают /set, состояния — /status. Панель подписывается на home/+/temp один раз и ловит температуру всех комнат, включая те, что вы заведёте завтра. Ниже — скетч кухни, дерево в таблице и правила, на которых оно не гниёт.
Уровни, каналы и wildcards
Слэш делит топик на уровни, и подписчик может вместо уровня поставить плюс: home/+/temp совпадёт с home/kitchen/temp и home/bedroom/temp, но не с home/kitchen/ceiling/temp — плюс ловит ровно один уровень. Решётка в конце ловит всё поддерево: home/# — весь дом. Это и есть главная экономия структуры: панель подписана одной строкой, а не списком из двадцати топиков, и не требует правки при появлении ванны.
Каналы — дисциплина направлений. Команда летит вниз в топик .../set: короткий payload «0», «1», «70», retain у команд не ставят. Состояние летит вверх в .../status с retain — там живёт логика из урока про retained-статус: брокер хранит последнее слово и отдаёт его новым подписчикам. Смешивать каналы нельзя: если плата и слушает команды, и публикует состояние в один топик, получите петлю — плата поймает собственный статус как команду. Весь план дома у нас уже был в уроке про схему топиков, здесь — практика одной комнаты.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const int PIN_RELAY = 5;const int PIN_TEMP = A0; // LM35 на аналоговом входеconst char* T_TEMP = "home/kitchen/temp";const char* T_LIGHT = "home/kitchen/light";unsigned long lastTemp = 0;bool relayOn = false; void statusPublish() { client.publish((T_LIGHT + String("/status")).c_str(), relayOn ? "on" : "off", true);} void callback(char* topic, byte* payload, unsigned int len) { char cmd = (len > 0) ? (char)payload[0] : 0; if (strcmp(topic, "home/kitchen/light/set") == 0) { relayOn = (cmd == '1'); digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW); statusPublish(); Serial.print("команда: "); Serial.println(cmd); }} void setup() { Serial.begin(9600); pinMode(PIN_RELAY, OUTPUT); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) delay(300); client.setServer("192.168.1.50", 1883); client.setCallback(callback); while (!client.connect("kitchen-node-01")) delay(1500); client.subscribe("home/kitchen/+/set"); // все команды этой комнаты statusPublish(); Serial.println("кухня в дереве: set слушаю, status и temp публикую");} void loop() { client.loop(); if (millis() - lastTemp >= 5000) { lastTemp = millis(); float t = (analogRead(PIN_TEMP) * 5.0 / 1023.0) * 100.0; // LM35 client.publish(T_TEMP, String(t, 1).c_str()); }}Смотрите, что даёт структура. Плата подписана на home/kitchen/+/set — любую новую команду комнаты, хоть light/set, хоть fan/set, она примет без перепрошивки. Состояние уходит в home/kitchen/light/status с retain — панель видит его при старте. Температура — в home/kitchen/temp без retain раз в пять секунд, и подписка home/+/temp соберёт весь дом. strcmp по топику в callback — маршрутизация в чистом виде: пришло в light/set — щёлкнули реле; заведёте fan/set — добавите ветку, а структура не изменится.
Конструкция (T_LIGHT + String("/status")) собирает имя канала из корня: корень описан один раз константой, а не размазан строками по коду — переезд на другой корень (был home, стал flat) это замена одной строки. Дисциплина payload отсюда же: одно значение — один топик. Конструкции «t=24.6;h=40» в одном сообщении — самодельный протокол без парсера, мы это уже разбирали в уроке про JSON-полезную нагрузку.
| Топик | Куда летит | retain | payload |
|---|---|---|---|
| home/kitchen/temp | вверх, телеметрия | нет | 24.6 |
| home/kitchen/light/status | вверх, состояние | да | on / off |
| home/kitchen/light/set | вниз, команда | нет | 0 / 1 |
| home/bedroom/light/set | вниз, команда | нет | 0 / 1 |
| home/+/temp | подписка панели | — | ловит все комнаты |
| home/# | подписка отладки | — | всё дерево целиком |
- 1. Панель хочет температуру всех комнат сразу. Какая подписка это сделает?
- 2. Почему команду и состояние нельзя публиковать в один топик?
- Топик — путь в дереве: корень проекта, комната, сущность, канал через слэш
- Каналы дисциплинируют направление: команды вниз в /set без retain, состояния вверх в /status с retain
- + ловит один уровень (home/+/temp — все комнаты), # — всё поддерево (home/# — отладка)
- Подписка платы home/kitchen/+/set принимает будущие команды комнаты без перепрошивки
Частые вопросы по теме урока
- Панель хочет температуру всех комнат сразу. Какая подписка это сделает?
- Плюс ловит ровно один уровень: home/+/temp соберёт кухни, спальни и ванны. home/# шире нужного — это весь дом, home/kitchen/temp — одна комната. home/+/temp
- Почему команду и состояние нельзя публиковать в один топик?
- Публикация в топик, на который сам подписан, вернётся тебе же через callback: реле щёлкнет от собственного статуса. Команды — set, состояния — status. Плата поймает собственный статус как команду — петля