Свет на кухне я завёл через MQTT и неделю жил с занозой: откроешь телефонную панель, а там прочерки. Плата-то работает, реле щёлкает, но своё состояние она рассказывает только в момент публикации — а публикует она, когда что-то меняется. Панель включилась после платы, роутер перезагрузился, телефон проснулся из сна — и все, кто подписался «не вовремя», сидят без статуса до следующей команды. Хуже всех было Home Assistant: после рестарта он держал реле в «неизвестно» до первого клика, а кликать вслепую лампу, когда в комнате спит ребёнок, то ещё развлечение.
Лечится это флагом retain в заголовке PUBLISH — третьим аргументом publish(). Брокер с retain запоминает последнее сообщение каждого топика у себя, и как только приходит новый подписчик — отдаёт ему это значение мгновенно, не дожидаясь, пока устройство вздумает что-то рассказать. Панель открылась — статус уже на экране. Вся механика уместится в скетч ниже: реле на D5, статус с retain, периодическое освежение, плюс правило, чего retain-ом помечать нельзя.
retain: флаг, который живёт у брокера
Важно понять, где хранится retain: не на плате, а у брокера. Вы один раз публикуете home/kitchen/light/status = "on" с флагом retain — и брокер кладёт эту пару в свою таблицу. Пришёл новый подписчик на этот топик — брокер первым же делом выдаёт ему сохранённое значение, ещё до всяких публикаций от платы. Сообщение может быть час старым — для подписчика оно придёт как свежее. Именно поэтому старое retained-значение называют «последним словом топика»: оно висит в брокере, пока его не перезапишет новое или вы не вытрете его пустым payload с тем же retain.
Отсюда и правило отбора: retain-ом помечают состояния, а не события. «Реле сейчас включено», «температура была 24,6» — да, новому подписчику полезно знать это сразу. «Счётчик насчитал импульс», «кнопка нажата» — ни в коем случае: брокер запомнит событие навсегда, и каждая панель, подписавшаяся через неделю, получит чужой импульс как свежий. Про раздачу статусов после рестарта платы у нас есть отдельный урок, а здесь — сам механизм.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const int PIN_RELAY = 5;const char* T_STATUS = "home/kitchen/light/status";const char* T_SET = "home/kitchen/light/set"; bool relayOn = false;unsigned long lastBeat = 0; void publishStatus() { client.publish(T_STATUS, relayOn ? "on" : "off", true); // true = retain Serial.print("status -> "); Serial.println(relayOn ? "on" : "off");} void callback(char* topic, byte* payload, unsigned int len) { char cmd = (len > 0) ? (char)payload[0] : 0; if (cmd == '1') relayOn = true; if (cmd == '0') relayOn = false; digitalWrite(PIN_RELAY, relayOn ? HIGH : LOW); publishStatus(); // статус уезжает сразу после смены} 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);} void loop() { if (!client.connected()) { if (client.connect("kitchen-light-01")) { client.subscribe(T_SET); publishStatus(); // retained-значение ляжет в брокер сразу Serial.println("подключился, статус с retain выложен"); } return; } client.loop(); if (millis() - lastBeat >= 30000) { // раз в полминуты освежаем retain lastBeat = millis(); publishStatus(); }}Три вызова publish() в этом скетче, и все с retain — потому что топик статусный. Первый случается сразу после connect(): плата взяла подписку на команды и тут же выложила своё состояние; даже если реле не переключалось неделю, брокер уже знает ответ на вопрос «а свет сейчас какой?». Второй — в callback(): поменяло реле состояние — сообщило. Третий — по таймеру раз в тридцать секунд: страховка на случай, если брокер работал без сохранения сессии и его таблица retain обнулилась, плюс статус в логе живой, а не недельной давности.
Аргумент relayOn ? "on" : "off" — это ровно одна строка, никаких json и префиксов: топик уже сказал, что здесь состояние света. Полезная привычка: статус публикуется из той же функции, что и смена состояния, — тогда забыть про обновление невозможно. Классическая ошибка — щёлкать реле в callback, а publish писать «когда-нибудь потом»: потом не наступает, панель врёт, счёт проходит мимо.
| Сообщение | retain? | Почему |
|---|---|---|
| Состояние реле, свет/замок/заслонка | да | новый подписчик должен увидеть текущее положение сразу |
| Последние показания датчика | да | панель при старте показывает температуру, а не прочерк |
| Команды set, «включи на 70» | нет | иначе брокер раздаст старую команду каждому новому клиенту |
| Импульсы счётчика, события кнопок | нет | событие зависнет в брокере и «выстрелит» через неделю |
| Тревоги и оповещения | нет | сработавшая тревога не должна встречать подписчика через месяц |
- 1. Панель включилась через час после платы. Что она получит сразу после subscribe()?
- 2. Что из этого категорически нельзя публиковать с retain?
- retain — третий аргумент publish(): брокер хранит последнее значение топика и отдаёт его новым подписчикам мгновенно
- Хранит брокер, а не плата: перезапись — новой публикацией, удаление — пустым payload с retain
- retain помечает состояния, события и команды — никогда: они обязаны умирать сразу после доставки
- Публикуйте статус сразу после subscribe() и освежайте по таймеру — панель тогда не знает слова «прочерк»
Частые вопросы по теме урока
- Панель включилась через час после платы. Что она получит сразу после subscribe()?
- Брокер хранит последнее retain-сообщение каждого топика и выдаёт его новому подписчику немедленно — возраст записи роли не играет. Retained-значение из брокера, даже если оно час старое
- Что из этого категорически нельзя публиковать с retain?
- Событие с retain зависает в брокере: панель, подписавшаяся через неделю, получит чужой импульс как свежий. События публикуются без retain. Импульсы счётчика воды