Вечер пятницы. В Home Assistant щёлкаю переключатель «Свет в коридоре» — карточка наливается цветом, а в коридоре темно. Щёлкаю обратно, смотрю в монитор порта: реле даже не дёрнулось. Плата жива, Wi-Fi жив, брокер жив, а команда умерла где-то между интерфейсом и пином D7.
Вторая половина истории зеркальная: неделей позже свет включили физической кнопкой у двери, а Home Assistant ещё час уверенно рисовал «выключено». Тогда я и понял, что сделал полдела: настроил поток команд вниз, но забыл поток отчётов наверх. Свет в умном доме — не одна стрелка, а конвейер из трёх звеньев: команда, действие, отчёт.
Первая проверка, которую я тогда сделал, — перечитал setup: подписка была, но только сразу после первого connect. А плата за вечер успела потерять Wi-Fi и переподключиться — при чистой сессии брокер забывает подписки, и после reconnect их надо восстанавливать заново. Так у меня появился личный чек-лист: connect, потом subscribe, потом отчёт о состоянии — и всё это при каждом переподключении, а не единожды при старте.
Команда, действие, отчёт
Топики я развожу жёстко. dacha/light/cmd — команда: сюда пишет только Home Assistant, плата подписана. dacha/light/state — отчёт: сюда пишет только плата, HA подписан. Общих каналов «и туда и сюда» не держу: чужая публикация в общий топик превращает свет в лотерею. Payload команды — 1 и 0, и это часть договора, а не вкусовщина: словарь значений должен совпадать в скетче и в конфиге сущности до последнего символа.
Отчёт — не украшение, а обязательное звено: HA рисует состояние карточки из state-топика, а не из факта нажатия. Без отчёта переключатель показывает отправленную команду, а не реальность — и после рестарта платы свет на экране и в комнате расходятся навсегда. Реле вешаю на D7 через модуль с оптроном; про обвязку, оптроны и грабли 220 вольт был урок про модуль реле — здесь беру готовый узел.
QoS для команд я почти всегда оставляю нулевым: потерянная команда — это просто ещё раз щёлкнуть карточку, а очередь недоставленных пакетов после долгого отключения способна включить свет час спустя, когда он уже никому не нужен. Для отчёта ситуация другая: нулевой уровень здесь тоже терплю, потому что отчёты идут часто, а актуальность важнее гарантии доставки устаревшего значения.
#include <PubSubClient.h> WiFiClient net;PubSubClient client(net);const char* ID = "esp32-hall-light";const char* T_CMD = "dacha/light/cmd";const char* T_STATE = "dacha/light/state";const int RELE = 7;const int BTN = 2; int svet = 0;int wanted = 0;bool needApply = false;unsigned long lastBtn = 0; void applyLight(int v) { svet = v; if (v == 1) { digitalWrite(RELE, HIGH); } else { digitalWrite(RELE, LOW); } client.publish(T_STATE, String(v)); Serial.print("svet="); Serial.println(v);} void callback(char* topic, byte* payload, unsigned int length) { String t = String(topic); if (t != T_CMD) { return; } String p = ""; for (unsigned int i = 0; i < length; i++) { p += (char)payload[i]; } if (p == "1") { wanted = 1; needApply = true; } else if (p == "0") { wanted = 0; needApply = true; } else { Serial.print("IGNOR payload: "); Serial.println(p); }} void setup() { Serial.begin(9600); pinMode(RELE, OUTPUT); digitalWrite(RELE, LOW); pinMode(BTN, INPUT_PULLUP); 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(T_CMD); applyLight(0);} void loop() { if (!client.connected()) { if (client.connect(ID)) { client.subscribe(T_CMD); } delay(3000); return; } client.loop(); if (needApply) { applyLight(wanted); needApply = false; } unsigned long now = millis(); if (digitalRead(BTN) == LOW && now - lastBtn > 300) { lastBtn = now; if (svet == 1) { applyLight(0); } else { applyLight(1); } }}В callback команда только кладётся в переменные wanted и needApply — исполнение живёт в loop. Причина не в эстетике, а в устройстве библиотеки: callback вызывается изнутри client.loop(), и долгая работа там оборачивается пропущенными пингами и разрывами. Дисциплину разбора входящих мы выстраивали в уроке про подписку и callback; здесь к ней добавляется единственное новое звено — функция applyLight.
applyLight — единственная точка, где меняется свет: она щёлкает реле и публикует отчёт, другого пути нет. Команда из HA и нажатие физической кнопки ведут в одну и ту же функцию, поэтому двусторонняя синхронизация получается сама собой: кнопка меняет свет, applyLight публикует state, и карточка в HA перерисовывается за долю секунды. Кнопка на D2 читается с INPUT_PULLUP и паузой 300 мс: заодно с антидребезгом это защита от зажатой кнопки, которая иначе гоняла бы свет туда-сюда.
Контакт кнопки замыкает на землю, подтяжка к питанию встроена в плату — лишних резисторов не нужно. Пауза 300 мс после нажатия — это одновременно антидребезг и защита от зажатия. А сразу после connect плата отчитывается фактическим состоянием, даже если никто не спрашивал: после перезагрузки роутера карточка в HA узнаёт правду за секунды, а не после первой команды.
| Поток | Куда идёт | Кто отправляет | Что делает плата |
|---|---|---|---|
| Команда | dacha/light/cmd | Home Assistant | callback кладёт wanted и needApply, ничего не исполняет |
| Действие | пин D7 | плата, из loop | applyLight щёлкает реле |
| Отчёт | dacha/light/state | плата, из applyLight | publish фактического состояния после любого изменения |
| Кнопка | пин D2 | человек | тот же applyLight: свет и отчёт меняются вместе |
Switch или light: выбор до первого payload
В Home Assistant у реле два пути: платформа switch и платформа light. Switch проще, но у него тонкое место по умолчанию: payload_on и payload_off — это ON и OFF в верхнем регистре. Скетч, ждущий единицу, молча проигнорирует такую команду: сравнение строк строгое, никакой автозамены регистра не будет. Я либо переопределяю payload в config на 1 и 0, либо сравниваю в скетче то, что обещал в нём же, — главное, что словарь один и записан в двух местах одинаково.
Light нужен, когда есть яркость: brightness_command_topic и значения 0–255, для которых на плате понадобится ШИМ — механика каналов разобрана в уроке про ШИМ. Моё правило с условием: обычное реле коридора — switch, ленты и диммеры — light. Переключать платформу можно и позже, но карточки и автоматизации придётся перенавешивать, поэтому выбор я делаю до первой публикации config.
Честный state окупается на автоматизациях. Ночной сценарий «если свет в коридоре включили после полуночи — пусть горит вполсилы» читает именно state-топик: если отчёт врёт или молчит, автоматизация принимает решения по призракам. У меня один раз охранная логика два часа считала свет включённым из-за молчащего отчёта — с тех пор правило «плата отчитывается всегда» в моих проектах не обсуждается.
Тупик, на котором я просидел вечер: свет в HA моргал. Отчёт уходил только сразу после команды, а команда приходила, пока плата висела на reconnect. HA успевал показать включено, реле щёлкало позже, и в какой-то момент карточка показывала не то, что в комнате. Лечится одной строкой: applyLight отчитывается при любом пути изменения, и сразу после connect плата шлёт фактическое состояние, даже если его никто не спрашивал.
Для проверки платы без умного дома у меня осталась команда из арсенала брокера: mosquitto_pub -t dacha/light/cmd -m 1 прямо на Raspberry. Реле щёлкнуло — плата при чём, иди в HA; молчит — смотри подписку и коды подключения из урока про статусы. Порядок такой экономит вечера: сначала плата послушна голому брокеру, и только потом в цепочку включается интерфейс.
Когда реле стало три, я перешёл на длинные имена: dacha/hall/light/cmd, dacha/pump/water/cmd — помещение, устройство, направление. Шаблон applyLight при этом переезжает копипастой с правкой двух констант, и каждая плата слушает ровно свой топик. Общая модель ролей издателя и подписчика разобрана в уроке про pub-sub основы — там же про то, почему топики придумывают до кода, а не после.
- Конвейер света: команда в cmd-топик, исполнение в loop, отчёт в state-топик; без отчёта карточка показывает команду, а не факт
- applyLight — единственная точка изменения: реле и публикация всегда вместе, кнопка и HA ведут в одну функцию
- В callback — только флаги: publish и долгая работа из callback рвут keepalive
- Payload — договор с точностью до регистра: у switch по умолчанию ON и OFF, сверяй со скетчем до символа
- Проверка без HA: mosquitto_pub в cmd-топик — сначала плата послушна брокеру, потом цепляй интерфейс
- 1. Home Assistant отправил команду, свет включился. Зачем ещё отчёт в state-топик?
- 2. Почему реле щёлкает не в callback, а в loop?
- 3. Свет включили физической кнопкой. Что должно произойти в MQTT?
- 4. С каким payload платформа switch в HA включает нагрузку по умолчанию?
- 5. Плата перезагрузилась, свет был выключен. Что правильнее сделать сразу после connect?
Частые вопросы по теме урока
- Home Assistant отправил команду, свет включился. Зачем ещё отчёт в state-топик?
- Переключатель в интерфейсе отражает state-топик. Без отчёта он показывает то, что ты хотел, а не то, что реально случилось на пине. HA рисует состояние из state: без него карточка показывает команду, а не факт
- Почему реле щёлкает не в callback, а в loop?
- callback вызывается из client.loop(): задержка там оборачивается пропущенными пингами. Схема у всех библиотек MQTT одна — флаги в callback, работа в loop. publish и долгая работа внутри callback рвут keepalive
- Свет включили физической кнопкой. Что должно произойти в MQTT?
- cmd-топик принадлежит HA, плата туда пишет только в исключительных схемах. Кнопка меняет свет через applyLight, а отчёт уезжает в state — карточка обновляется. Ничего: HA не знает про кнопку
- С каким payload платформа switch в HA включает нагрузку по умолчанию?
- По умолчанию ON и OFF в верхнем регистре. Скетч со сравнением payload == 1 такое молча пропустит: либо переопредели payload в config, либо сравнивай ON. ON в верхнем регистре
- Плата перезагрузилась, свет был выключен. Что правильнее сделать сразу после connect?
- После reconnect подписка и карточка живут старыми данными. Отчёт о фактическом состоянии сразу после connect — дешёвая страховка от рассинхрона. Опубликовать фактическое состояние в state-топик