К третьей неделе моего MQTT-дома по деталям было собрано всё: термометр публиковал показания из millis-цикла, реле слушало команды с белым списком, консольный sub показывал поток. А дом всё ещё не был домом: свет включался из скетча-тестера, влажность жила только в журнале брокера, а о смерти платы в ванной я узнавал по графику, который замолкал. Проектом это стало в тот вечер, когда всё соединилось в одну систему с Home Assistant: дашборд, который рисует сам себя, статусы, которые живут без меня, и одна кнопка на весь свет.
Здесь собираю раздел в один скетч и одну схему: датчики температуры и влажности, реле ночника, вентилятор с автоматикой по влажности, статусы живости через last will и retain, discovery-паспорты для HA и групповая команда. Половина этих слов уже была по отдельности в прошлых уроках — теперь важны порядок сборки и контрольные точки: что проверять после каждого шага и как понять, что узел готов. Мой первый подход выглядел гордо: три устройства сразу, ноль проверок — и неделя распутывания вместо трёх вечеров.
Карта топиков: сначала бумага
Мой финальный дом живёт на шести ветках одного префикса: home/bedroom/temp и home/bedroom/hum — показания DHT22 раз в 30 секунд, по скорости явления, как в уроке про публикацию показаний; home/bedroom/light/cmd и home/bedroom/light/state — команды и отчёты ночника в разных ветках; home/bedroom/fan/state — состояние вентилятора, которым управляет автоматика платы; home/bedroom/avail — живость; home/all/light/cmd — групповая кнопка. Правило веток cmd и state — не перестраховка: эхо-ловушка из урока про групповые команды в финальном доме бьёт сильнее всего, потому что устройств много и волн больше.
Две вещи я рисую на бумаге до кода: кто публикует и кто слушает каждую ветку. Издатель у показаний — плата, слушатель — HA; у cmd издатель — HA, слушатель — плата; у state наоборот. Через месяц эта таблица окупается при каждом новом устройстве: добавить комнату — значит скопировать ветки и поменять имя, а не изобретать схему заново.
Пара решений из этой карты, которые вызывали у меня споры с самим собой. Вентилятор не имеет cmd — им управляет только автоматика платы, и HA видит его сущность read-only; когда захочу ручной режим, добавлю ветку cmd и тот же приём override из группового урока: ручная команда ставит паузу автоматике на десять минут, иначе борьба «человек против влажности» превращается в вечное перещёлкивание. Показания DHT22 не имеют retain: последние значения мне не нужны — нужен живой поток, а устаревшее retained число рисует график, которого нет.
Живость платы решается парой last will плюс retained online. При connect клиент завещает брокеру: если отвалюсь без прощания — положи home/bedroom/avail со значением offline и retain-флагом; сразу после успешного connect плата публикует online с тем же retain. У PubSubClient завещание — аргументы connect: топик, QoS, retain, сообщение. Смысл retain здесь двоякий: HA видит статус сразу после своего рестарта, не дожидаясь первой публикации, а окно keepalive в 15 секунд означает, что выдернутая из розетки плата объявится мёртвой примерно за пятнадцать секунд. Проверено выдёргиванием — самый честный тест этого урока.
Зачем avail, если есть график показаний? График замирает — и это косвенный признак, требующий додумывать: может, датчик молчит, а плата жива. Avail — прямой ответ одним словом, на который навешиваются автоматизации: «если bedroom недоступен и меня нет дома — уведоми». Тонкость протокола: last will срабатывает при нечистом разрыве; клиент, ушедший через честный disconnect, воля не нужна — брокер и так видит уход. Мои платы так не прощаются — у них только питание и Wi-Fi, поэтому воля в каждом скетче обязательна.
#include <WiFi.h>#include <PubSubClient.h>#include <DHT.h> const int SVET = 7; // реле ночникаconst int FAN = 12; // вентилятор через MOSFETDHT dht(4, DHT22);WiFiClient net;PubSubClient client(net); unsigned long lastPub = 0;const unsigned long PERIOD = 30000; // показания раз в 30 секундint wantLight = -1; // флаг из callbackint fan = 0; void publishDiscovery() { // один раз в setup, с retain; буфер поднят до connect client.publish("homeassistant/switch/bedroom_light/config", "{\"name\":\"Nochnik\",\"cmd_t\":\"~/cmd\",\"stat_t\":\"~/state\"," "\"uniq_id\":\"bedroom_light\",\"~\":\"home/bedroom/light\"}", true);} void callback(char* topic, byte* payload, unsigned int length) { String msg = ""; for (unsigned int i = 0; i < length; i++) { msg += (char)payload[i]; } if (msg != "1" && msg != "0") { return; } // белый список payload wantLight = msg.toInt(); // только флаг, работа в loop} void reconnect() { while (!client.connected()) { if (client.connect("esp-bedroom", "home/bedroom/avail", 1, true, "offline")) { client.publish("home/bedroom/avail", "online", true); client.subscribe("home/bedroom/light/cmd"); client.subscribe("home/all/light/cmd"); } else { delay(3000); } }} void setup() { Serial.begin(115200); pinMode(SVET, OUTPUT); pinMode(FAN, OUTPUT); dht.begin(); client.setBufferSize(512); // до connect: паспорт ~300 байтов client.setServer("192.168.1.50", 1883); client.setCallback(callback); reconnect(); publishDiscovery();} void loop() { if (!client.connected()) { reconnect(); } client.loop(); if (wantLight >= 0) { digitalWrite(SVET, wantLight ? HIGH : LOW); client.publish("home/bedroom/light/state", wantLight ? "1" : "0", true); wantLight = -1; } unsigned long now = millis(); if (now - lastPub >= PERIOD) { lastPub = now; float h = dht.readHumidity(); float t = dht.readTemperature(); if (isnan(h) || isnan(t)) { return; } // датчик молчит — пропускаем client.publish("home/bedroom/temp", String(t, 1).c_str()); client.publish("home/bedroom/hum", String(h, 0).c_str()); if (h >= 65 && fan == 0) { fan = 1; digitalWrite(FAN, HIGH); } if (h <= 55 && fan == 1) { fan = 0; digitalWrite(FAN, LOW); } client.publish("home/bedroom/fan/state", fan ? "1" : "0", true); }}Discovery — то, за что в HA любят MQTT: плата сама рассказывает дашборду, что у неё есть, и карточка появляется без ручного YAML. Схема: при старте плата публикует один раз, обязательно с retain, JSON-паспорт в homeassistant/switch/bedroom_light/config; HA слушает префикс homeassistant/ и собирает из паспорта сущность: имя, командный топик, топик состояния, уникальный идентификатор. Тильда в паспорте — сокращение префикса, чтобы не писать home/bedroom/light трижды. Про брокер под HA одно мнение с историей: встроенный в HA брокер я пробовал и ушёл на внешний Mosquitto — жалобы на его капризность слышал и от других, а внешний даёт те же консольные утилиты из урока про установку.
Число с условием, которое обошлось мне в вечер: паспорт весит 200–300 байтов, буфер PubSubClient по умолчанию 256 — длинный конфиг молча не уходит, publish возвращает 0, карточка не появляется, и никаких сообщений об этом нет. setBufferSize(512) в setup до первого connect решает; диагностика та же, что в первом скетче PubSubClient: каждый вызов проверяется на месте. Два правила discovery: публикация в setup один раз, никогда в loop — HA захлёбывается карточками; и стирание сущности — пустой payload в тот же config-топик, как retained-призрак из урока про мониторинг.
Проверяется discovery и без HA: консольная подписка на homeassistant/# показывает паспорт одним пакетом сразу после прошивки — если он прилетел, карточка в HA появится после простого обновления интеграции. И честная оговорка для тех, у кого HA пока нет вообще: весь этот уровень не обязателен. Карта топиков, avail-статусы и групповые команды работают с голым брокером; мой тестовый стенд месяц жил без дашборда — с одним mosquitto_sub в терминале tmux. HA добавляет к дому интерфейс и автоматизации, но суть проекта — в топиках: если карта правильная, дашборд меняется как перчатки.
| Компонент | Топики | Как проверяю |
|---|---|---|
| DHT22: температура и влажность | home/bedroom/temp и /hum, раз в 30 с | sub -v показывает два числа; карточки в HA ожили после discovery |
| Реле ночника | home/bedroom/light/cmd и /state | кнопка в HA щёлкает реле, state возвращает 1/0 в карточку |
| Вентилятор с гистерезисом | home/bedroom/fan/state | влажность 65 — включился, 55 — выключился; в HA только чтение |
| Статус платы | home/bedroom/avail | выдёргиваю питание: через 15 секунд сущность серая |
| Групповой свет | home/all/light/cmd | одна кнопка гасит все платы, отчёты приходят в каждый state |
Порядок сборки: по одному узлу
Порядок, который уберёг меня во второй попытке. Ноль: брокер уже стоит и слушает сеть из урока про Mosquitto. Первый узел — плата спальни с avail и без discovery: сырой поток на втором экране, выдёргивание питания, offline за пятнадцать секунд. Когда узел сам по себе прожил сутки — добавляю discovery и карточку. Потом второй узел по той же спирали, потом групповая команда в самом конце, когда есть что группировать. Моя первая попытка была обратной: три устройства, всё сразу, ноль проверок — неделя распутывания вместо трёх вечеров.
Куда расти после проекта: во-первых, в глубину — пароли из урока про брокера, а при выносе наружу — порт 8883 с TLS: группа, гасящая свет, не должна слушать чужую сеть. Во-вторых, в ширину: каждая новая комната — копия узла спальни со сменой имени в трёх-четырёх ветках; паспорт с новым именем уехал — карточка появилась сама. Третий уровень — автоматизации HA поверх готовых сущностей: они собираются мышью и не требуют ни строки в скетчах плат.
Последняя вещь, которую я проверяю в готовом доме, — поведение при отказах: выключите брокер на минуту и посмотрите, как платы заходят в reconnect-цикл с паузой три секунды, а не штормом в сотни попыток, как разбиралось в уроке про публикацию с ESP32. Дом, который пережил рестарт брокера, выдёргивание питания и моргнувший Wi-Fi без вашей помощи, — это и есть итоговый проект; остальное — комнаты, датчики, автоматизации — уже копирование веток и смена имён.
- Карта топиков до кода: home/<комната>/<устройство>/(cmd|state), показания по скорости явления, статусы по событию
- Живость = LWT offline с retain при connect плюс retained online после него; мёртвая плата объявляется за окно keepalive — 15 секунд
- Discovery публикуется один раз в setup, с retain, в homeassistant/<тип>/<имя>/config; буфер 512 поднимается до connect
- Команды и отчёты в разных ветках; автоматика вентилятора — гистерезис 65/55 вместо одного порога
- Сборка по спирали: брокер, узел с avail, discovery, карточка, группа; финальный тест — рестарт брокера и выдёргивание питания
- 1. Плату выдернули из розетки. Через какое время HA увидит offline при окне keepalive 15 секунд?
- 2. Зачем discovery-паспорту retain-флаг?
- 3. Discovery-паспорт не появляется, publish возвращает 0. Самая вероятная причина?
- 4. Где публиковать discovery-паспорт?
- 5. Вентилятор управляется влажностью. Почему гистерезис 65/55 лучше одного порога 60?
Частые вопросы по теме урока
- Плату выдернули из розетки. Через какое время HA увидит offline при окне keepalive 15 секунд?
- Завещание живёт у брокера: при истечении окна keepalive он сам публикует offline в avail-топик. Retain гарантирует, что мёртвую плату увидят и новые подписчики. Через 15 секунд: брокер опубликует last will
- Зачем discovery-паспорту retain-флаг?
- После рестарта HA или брокера сущности восстанавливаются из retained config-топиков. Без retain паспорта пришлось бы переопубликовывать руками. HA перечитывает конфигурации из retained после рестарта: без флага сущности исчезают
- Discovery-паспорт не появляется, publish возвращает 0. Самая вероятная причина?
- Паспорт весит 200–300 байтов, буфер по умолчанию 256. setBufferSize(512) в setup до первого connect — и publish снова возвращает 1. Паспорт длиннее буфера 256 байтов: нужен setBufferSize(512) до connect
- Где публиковать discovery-паспорт?
- Одна публикация в setup с retain. Повтор из loop захлёбывает HA карточками, а пустой payload в config-топик — штатный способ удалить сущность. Один раз в setup с retain; удаление — пустой payload в config-топик
- Вентилятор управляется влажностью. Почему гистерезис 65/55 лучше одного порога 60?
- На одном пороге влажность около 60 включает и выключает вентилятор каждый цикл. Зазор в 10 пунктов даёт тишину между переключениями; трафик тут ни при чём — переключения идут по событию. Гистерезис экономит MQTT-трафик в двадцать раз