Гаражный свет я перевёл на MQTT в первую зиму: реле-модуль, ESP32, кнопка в приложении. Полгода всё было прекрасно — до вечера, когда кнопка показывала «включено», а в гараже было темно. Причин у такого фокуса три, и все честные: команда не дошла, потому что Wi-Fi моргнул в момент отправки; дошла, но плата не успела применить; дошла и щёлкнула — а перегорела лампа. Без ответного статуса все три выглядят одинаково: кнопка врёт, и вы гадаете, стоя в гараже с фонариком. Фонарик в руке — честный индикатор того, что система не договаривает.
Классический скетч из первых уроков MQTT делает одну половину работы: дёргает пин по команде. О своём состоянии он молчит. Между тем в промышленных реле-модулях, тех же Wiren Board, устройство после каждого переключения само публикует фактическое состояние в топик канала — дашборд читает факт, а не заявку. Этот цикл «команда → действие → статус» собирается своими руками за полчаса, и после него кнопка в приложении перестаёт быть гадалкой — пара строк статуса разделяет кнопку и пульт с обратной связью.
Строим полный цикл: словарь значений в callback, щелчок реле из loop, статус в ответный топик с retain — и прогоняем его по краям: потерянная команда, повторная команда, статус без изменения. Половина шагов уже готова: callback мы писали в отдельном уроке, publish по таймеру — тоже; нового здесь словарь, pending-паттерн и статус с retain, примерно двадцать строк.
Команда: словарь значений и стадия приёма
Payload с брокера — байты из внешнего мира, и доверять им слепо нельзя, мы это проходили в уроке про subscribe и callback. Здесь правило превращается в словарь: on и off, плюс цифровые 1 и 0 для ручных публикаций из консоли; всё остальное игнорируется молча и по делу. Никакой инверсии «получил что угодно — щёлкни наоборот»: при QoS 1 брокер гарантирует минимум одну доставку, дубликат — норма, и инверсия на дубле мигнёт светом в прямом эфире; тему дубликатов разбирали в уроке про уровни QoS. Цифровой ряд — не альтернатива словарю, а второй его ряд: панель шлёт слова, человек из терминала — единицы, скетч понимает оба.
Вторая стадия — разделение труда между callback и loop. Callback только записывает цель в переменную pending: он вызывается из недр client.loop(), и publish оттуда — известная ловушка с петлёй разрывов, на которой я обжёгся в уроке про свет из Home Assistant. Действие и статус делаются в loop, после client.loop(): реле щёлкает, статус уезжает — и всё это при живом соединении. Бонус разделения — очередь: три нажатия подряд обрабатываются по одному за оборот loop, без шторма публикаций.
Отдельный статус-топик — не эстетика, а защита от эха. Если публиковать факт в общий топик, на который плата подписана широкой маской garage/#, она получит собственный отчёт как команду: on в словаре есть, pending встанет, сравнение с reported промолчит — беды нет, но в каждом новом скетче придётся держать эту петлю в голове. Раздельные имена cmd и status дешевле, чем час с mosquitto_sub в поисках того, кто говорит сам с собой.
Число с условием: между командой и щелчком реле проходит максимум один оборот loop — на ESP32 доли миллисекунды, на Uno пара миллисекунд. Человек такой лаг не видит, а брокер узнаёт правду раньше, чем вы поднимаете глаза на лампу.
#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); const int RELAY_PIN = 7;const int RELAY_ON = LOW; // модули с оптроном чаще щёлкают от LOW int pending = -1; // цель из callback; применит loopint reported = -1; // состояние, о котором брокер уже знает void applyAndReport() { int target = pending; pending = -1; digitalWrite(RELAY_PIN, target == 1 ? RELAY_ON : HIGH); // действие if (target != reported) { reported = target; // статус только по факту изменения client.publish("garage/light/status", target == 1 ? "on" : "off", true); Serial.print("статус уехал: "); Serial.println(target == 1 ? "on" : "off"); }} void callback(char* topic, byte* payload, unsigned int length) { String msg = ""; for (unsigned int i = 0; i < length; i++) { msg += (char)payload[i]; } msg.trim(); if (msg == "on" || msg == "1") { pending = 1; } else if (msg == "off" || msg == "0") { pending = 0; } // всё остальное - из внешнего мира: молча игнорируем} void reconnect() { while (!client.connected()) { if (client.connect("esp32-garage")) { client.subscribe("garage/light/cmd"); } else { delay(3000); } }} void setup() { Serial.begin(115200); pinMode(RELAY_PIN, OUTPUT); digitalWrite(RELAY_PIN, HIGH); // старт: свет погашен (модуль активный LOW) WiFi.begin(WIFI_SSID, WIFI_PASS); while (WiFi.status() != WL_CONNECTED) { delay(500); } client.setServer(MQTT_SERVER, 1883); client.setCallback(callback); reconnect(); client.publish("garage/light/status", "off", true); // сразу честный статус} void loop() { if (!client.connected()) { reconnect(); } client.loop(); // после него callback уже отработал if (pending >= 0 && client.connected()) { applyAndReport(); // действие и статус - из loop, не из callback }}Статус — не эхо команды, а факт: скетч публикует то, что стало с пином, после того как стало. Публикация стоит за сравнением с переменной reported: вторая та же команда не рождает второй публикации — изменение и есть событие, а тишина тоже информация. Ответный топик — сосед команды: garage/light/cmd для приказов, garage/light/status для фактов; через год по именам видно, где ездят приказы, а где отчёты. Имена я держу константами рядом в начале скетча: подписка панели и publish платы должны совпасть до байта, а переименование — дело одной строки.
retain на статусе — вторая половина смекалки: новый дашборд или перезапущенная панель подписывается и мгновенно получает последнее фактическое состояние, не дожидаясь следующего переключения; механику разбирали в уроке про retained-сообщения. Со статусом в паре работает last will: status говорит «какой я», а завещание от брокера — «меня больше нет», мы настраивали его в уроке про LWT. Вместе они закрывают обе стороны правды, и граница между ними простая: retained-статус отвечает на вопрос «что сейчас», завещание — на «жив ли отвечающий».
Про железо одно число с условием: большинство реле-модулей с оптроном щёлкают от LOW — «включено» означает ноль на пине, и светодиод на плате модуля горит тоже при LOW. В скетче это константа RELAY_ON = LOW, чтобы смысл не потерялся в инверсиях; перепутаете — свет будет включаться наоборот, и статус при этом честно врёт вместе с вами. Про 220 вольт, клеммы и предохранитель — отдельный разговор из основ.
Дашборд по статусу строит три состояния кнопки: серая — отчётов нет вообще, зелёная — статус совпал с приказом, жёлтая — приказ ушёл, статуса ещё нет. Жёлтое живёт две-три секунды, и это норма: пакет должен доехать, реле щёлкнуть, отчёт вернуться. Жёлтая кнопка, висящая минуту, — не каприз интерфейса, а честный сигнал потерянной команды или офлайна платы: по ней ставят QoS 1 и повтор, а не по догадкам. Цвета, разумеется, условны — важно, что состояний у кнопки три, а не два. Пара строк в панели экономит вечер с мультиметром.
Цикл вживую и его края
| Сценарий | Что видно в цикле | Что делаю |
|---|---|---|
| Команда, щелчок, статус | норма: cmd → щелчок → status с retain, вся петля за доли секунды | ничего: цикл работает |
| Команда ушла, статуса нет 2-3 с | кнопка висит в ожидании | QoS 1 для команды и повтор с ожиданием статуса: механизм гарантии — в уроке про реле с QoS |
| Статус on, а света нет | MQTT прав: пин и реле в порядке | ищу лампу, патрон, клеммы — 220 вольт вне MQTT |
| Повторная та же команда | тишина: публикаций нет | так и задумано: изменение — событие, дубль — нет |
В симуляторе цикл виден целиком, с одной подменой, о которой честно скажет подсказка: движок не умеет входящих сообщений MQTT, поэтому роль брокера играет строка в мониторе. На реальной плате те же строки прилетят в callback — остальной код не изменится ни на символ.
Следующий шаг после цикла — гарантия доставки: команда с QoS 1 и повтором, пока статус не вернулся. Это уже уровень договорённостей между панелью и платой, и там же живёт тема конфликта двух команд подряд. Для гаража мне хватило цикла из урока: потерянная команда за полгода случилась дважды, обе — во время моргнувшего роутера, обе видны в журнале как приказ без отчёта. Проверьте себя по таблице выше: если для каждого сценария вы знаете, какую строку кода будете открывать, урок свой отработал.
- Наберите on: реле щёлкнет, светодиод зажжётся, в мониторе появится строка статуса с retain — полный цикл за один оборот loop.
- Наберите on второй раз: тишина в статусах — изменение не случилось, событие не родилось. Найдите в коде строку сравнения с reported.
- Наберите hello: скетч ответит про словарь и ничего не переключит — payload из внешнего мира не проходит мимо словаря.
- Пошлите off и сразу on: два статуса подряд — так выглядит в журнале вечер, когда свет дёргают из трёх приложений.
- Цикл «команда → действие → статус»: cmd-топик для приказов, status-топик для фактов — имена не путать
- Словарь значений в callback: on/off/1/0, всё остальное игнорируется; инверсии нет — дубликат QoS 1 мигнёт светом
- Callback пишет цель в pending; реле щёлкает и статус публикуется из loop — publish из callback ведёт к петле разрывов
- Статус с retain и только по факту изменения: новый дашборд видит правду сразу, дубль команды не спамит
- Модули с оптроном чаще щёлкают от LOW: константа RELAY_ON = LOW держит смысл, старт — digitalWrite(HIGH)
- 1. Почему команда не должна инвертировать состояние реле?
- 2. Где выполняются действие с реле и публикация статуса?
- 3. Повторная команда on пришла, а статус не публиковался. Почему?
- 4. Что даёт retain на статус-топике?
- 5. В модуле с оптроном RELAY_ON = LOW. Что произойдёт при digitalWrite(RELAY_PIN, LOW)?
Частые вопросы по теме урока
- Почему команда не должна инвертировать состояние реле?
- QoS 1 гарантирует минимум одну доставку, и дубликат — норма. Инверсия на дубле превращает гарантию доставки в мигание: словарь on/off вместо toggle. При QoS 1 возможен дубликат: вторая доставка мигнёт светом обратно
- Где выполняются действие с реле и публикация статуса?
- Callback вызывается из client.loop(), и publish оттуда ведёт к петле разрывов. Callback пишет pending, действие и статус делает loop при живом соединении — порядок виден в коде: client.loop() стоит строкой выше ветки pending. В loop после client.loop(): callback лишь записывает цель в pending
- Повторная команда on пришла, а статус не публиковался. Почему?
- Публикация стоит за сравнением с reported: изменение — событие, дубль — нет. Тишина после повторной команды сама по себе информация. Буфер переполнился
- Что даёт retain на статус-топике?
- Брокер хранит последнее retained-сообщение и отдаёт его каждому новому подписчику: перезапущенная панель сразу показывает правду, не дожидаясь переключения. Тот же механизм, что у конфиг-команды в уроке про DHT22. Новый подписчик мгновенно получает последнее фактическое состояние
- В модуле с оптроном RELAY_ON = LOW. Что произойдёт при digitalWrite(RELAY_PIN, LOW)?
- Большинство модулей с оптроном активны при LOW: светодиод на плате модуля и щелчок совпадают с нулём на управляющем пине. Константа RELAY_ON держит смысл. Реле замкнёт нагрузку: «включено» при нуле на пине