Воскресенье, третий час возни с теплицей. В mosquitto_sub строка dacha/greenhouse/temp 21.4 мелькает исправно, брокер на Raspberry Pi здоров, а в Home Assistant на странице интеграции MQTT — пустой список. Никакой карточки сенсора, никакой истории температуры: с точки зрения умного дома устройство не существует, хотя данные идут прямо у него под носом.
Полтора часа я потратил на поиски кнопки «добавить устройство» в интерфейсе, пока не понял главное: её нет и не должно быть. Home Assistant никого не сканирует и ничего не угадывает — сущность оказывается в интерфейсе в единственном случае: когда устройство само пришлёт на брокер описание самого себя. Механизм называется MQTT Discovery, и после первой настройки каждый новый датчик добавляется в дом без единого касания интерфейса.
Сначала я пошёл другим путём: прописал датчик руками в YAML-конфигурации Home Assistant — сущность заработала за десять минут. Недостаток вскрылся на втором датчике: каждая новая точка измерения — правка файла на сервере и рестарт, а прошивки обновляются куда чаще, чем я готов ходить к серверу. Discovery переворачивает схему: конфигурация живёт на самом устройстве и приезжает в дом вместе с ним, сервер ни при чём.
Анатомия discovery-сообщения
Discovery-сообщение едет в обычный MQTT-топик с жёсткой структурой. Для температурного сенсора это homeassistant/sensor/greenh01/config. Слои, слева направо: homeassistant — служебный префикс, который интеграция слушает всегда; sensor — тип сущности, от него зависит вид карточки; greenh01 — идентификатор устройства, его придумываю я; config — фиксированное слово, смысл которого «это описание, а не данные». Перепутаешь любой слой — сущности не будет.
Внутри payload — JSON с полями. Минимум, который я пишу всегда: name — имя карточки в интерфейсе; state_topic — адрес, откуда читать показания; unit_of_measurement — единицы измерения; unique_id — уникальное имя сущности в масштабе всего дома. Сверху приятно добавить device_class со значением temperature: интерфейс сам подставит иконку термометра и график. Схема публикации показаний при этом та же, что в уроке про первую публикацию ESP32, а собирать JSON я привык через ArduinoJson — разбор и сборка разобраны в уроке про serializeJson, здесь повторяться не буду.
Идентификатор в топике — место и роль, а не адрес: greenh01, hall02, boiler01. IP-адрес я не использую ни в имени, ни в clientId: DHCP выдаёт адреса с запасом на сутки, а устройство живёт годами. Идентификатор влияет на имя сущности, поэтому короткий и говорящий идентификатор экономит время в списке из тридцати карточек. Иерархия топиков как API дома — тема, которую мы раскладывали в уроке про модель pub-sub.
#include <DHT.h>#include <PubSubClient.h>#include <ArduinoJson.h> #define DHTPIN 4DHT dht(DHTPIN, DHT22);WiFiClient net;PubSubClient client(net); const char* ID = "esp32-greenh01";const char* T_CFG = "homeassistant/sensor/greenh01/config";const char* T_STATE = "dacha/greenhouse/temp";unsigned long lastPub = 0; void announce() { DynamicJsonDocument doc(512); doc["name"] = "Теплица"; doc["state_topic"] = T_STATE; doc["unit_of_measurement"] = "°C"; doc["device_class"] = "temperature"; doc["unique_id"] = "greenh01_temp"; char buf[320]; int n = serializeJson(doc, buf); client.publish(T_CFG, buf, true); Serial.print("config: "); Serial.print(n); Serial.println(" bait, ushla s retain");} void setup() { Serial.begin(9600); dht.begin(); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer("192.168.1.50", 1883); while (!client.connect(ID)) { delay(2000); } announce();} void loop() { if (!client.connected()) { if (client.connect(ID)) { announce(); } delay(3000); return; } client.loop(); unsigned long now = millis(); if (now - lastPub >= 10000) { lastPub = now; float t = dht.readTemperature(); if (isnan(t)) { Serial.println("dht ne otvetil"); return; } client.publish(T_STATE, String(t, 1)); Serial.print("t="); Serial.print(now); Serial.print(" temp="); Serial.println(t); }}| Поле config | Что делает | Что будет, если убрать |
|---|---|---|
| name | имя карточки в интерфейсе | сущность назовётся greenh01 — искать в списке неудобно |
| state_topic | адрес, откуда HA читает показания | сущность появится, но останется вечно недоступной |
| unit_of_measurement | единицы на карточке и в графике | число без единиц, автоматизации по порогам писать труднее |
| unique_id | уникальное имя сущности в доме | при каждом реконнекте в интерфейсе плодится дубль |
| device_class | класс температуры, иконка, графики | безликая цифра без термометра в интерфейсе |
Грабля первая — префикс. home_assistant с подчёркиванием, homeassistant2, ha/config — выглядит правдоподобно и не работает: интеграция слушает ровно homeassistant/ и ничего больше. Подлость в том, что никто не ругается: брокер примет сообщение в любой топик, а Home Assistant просто проигнорирует чужой префикс. Диагностика — одна строка: mosquitto_sub -t homeassistant/# -v на машине с брокером показывает каждое discovery-сообщение целиком. Тот же приём я описывал, когда ставил брокер Mosquitto.
Грабля вторая — кривой JSON: лишняя запятая перед закрывающей скобкой, типографская кавычка из заметок вместо машинной, незакрытый блок. Свежие версии Home Assistant пишут об этом в журнал, старые молча пропускали. Грабля третья тоньше: state_topic в config отличается от топика, куда скетч реально публикует, — на одну букву или на уровень вложенности. Сущность появится, но вечно со статусом недоступна: HA смотрит в пустоту и честно сообщает об этом серым цветом карточки.
Грабля четвёртая — порядок запуска. Discovery-публикация до успешного connect возвращает false и молча теряется: сущности не будет до следующей попытки. Поэтому announce() вызывается строго после client.connect, а при переподключении — снова. Буфер под JSON держите с запасом: DynamicJsonDocument на 512 байт переваривает десяток полей, а serializeJson честно вернёт число записанных байтов — я печатаю его в Serial при каждом announce, чтобы заметить, когда описание упрётся в размер.
Retain: почему сущность живёт после перезапуска
Третий аргумент client.publish — retain. С ним брокер хранит последнее сообщение топика и отдаёт его каждому новому подписчику. Это делает discovery живучим: Home Assistant перезапустился, подключился — и получил сохранённый config, не дожидаясь новой публикации с платы. Сенсор шлёт описание после каждого удачного connect: пакет весит около 200 байт, случается раз в часы и дни, зато переживает и рестарт HA, и чистую базу брокера после переезда.
unique_id — страховка от клонов: без него каждое переподключение устройства рождало в интерфейсе новую сущность с тем же именем, и к концу месяца список выглядел как каталог двойников. Формат у меня простой: роль и тип, greenh01_temp. Обратная сторона retain — удаление: сущность убирается публикацией пустого payload в тот же discovery-топик, retained-сообщение затирается, и карточка исчезает. Так я вычищал тестовые датчики после экспериментов.
Одно устройство часто несёт несколько величин: мой DHT22 в теплице меряет и температуру, и влажность. На каждую величину — своё discovery-сообщение: greenh01_temp и greenh01_hum с отдельными state_topic. Так HA рисует две карточки с правильными единицами, а автоматизации по влажности не трогают температурные. Общего config на оба значения я избегаю: единицы у величин разные, и сущность с двумя смыслами ломает график.
Вопрос, который я долго решал неправильно: публиковать config один раз при старте или при каждом connect? Ответ — при каждом. Операция копеечная: 200 байт раз в несколько часов, зато сценарий «переехал на новый брокер с пустой базой» перестаёт быть катастрофой — устройство само зарегистрируется в первые секунды. Точно так же бесплатно настраивается второй дашборд: поставил брокер на другой машине, подключил интеграцию — конфиги приехали из retain-памяти.
Подключение к сети я держу неблокирующим: попытка connect по таймеру, между попытками client.loop() и обслуживание датчика, иначе discovery уедет, а живые показания подвиснут. Каркас с диагностикой и повторными попытками мы собирали в уроке про статусы подключения, а публикацию по millis без delay — в уроке про показания в millis-цикле. Здесь эти блоки просто склеиваются с announce().
Retain у самих показаний я не ставлю: температура обновляется каждые десять секунд, и сохранённое старое значение HA перечитает при первом же свежем пакете. Исключение — редкие события: состояние калитки или счётчик импульсов, где последнее значение важно удержать до следующего. Арифметика старта скромная: десяток устройств по 200 байт конфигов — около двух килобайт трафика при рестарте брокера, незаметно даже для слабого роутера.
- Discovery — описание, которое устройство шлёт само: homeassistant/sensor/<id>/config, JSON с name, state_topic, unit_of_measurement, unique_id
- state_topic в config и топик публикации совпадают посимвольно: сущность с расхождением вечно недоступна
- Префикс только homeassistant/ — остальное интеграция игнорирует молча; проверка — mosquitto_sub -v на homeassistant/#
- Публикуй config с retain после каждого удачного connect: около 200 байт, зато переживает рестарты HA и переезд брокера
- unique_id избавляет от клонов при реконнектах; пустой payload в config — штатное удаление сущности
- 1. Где должен лежать discovery-топик температурного сенсора с идентификатором greenh01?
- 2. Что станет с сущностью, если state_topic в config отличается от топика реальной публикации?
- 3. Зачем в config нужен unique_id?
- 4. Сущность не появилась. Что я делаю первым делом?
- 5. Что делает пустой payload, опубликованный в discovery-топик с retain?
Частые вопросы по теме урока
- Где должен лежать discovery-топик температурного сенсора с идентификатором greenh01?
- Схема: homeassistant/<тип>/<идентификатор>/config. Данные едут в state_topic, а описание — в отдельный служебный топик с фиксированным словом config. homeassistant/sensor/greenh01/config
- Что станет с сущностью, если state_topic в config отличается от топика реальной публикации?
- Карточка создаётся, но данных не видит никогда: HA слушает топик из config, а показания уезжают в другой. Сверяй обе строки посимвольно. Сущность появится, но вечно со статусом недоступна
- Зачем в config нужен unique_id?
- Уникальный идентификатор — способ сказать HA, что это та же самая сущность. Без него реконнекты порождают клонов, а история показаний дробится между ними. Без него каждое переподключение плодит дубликаты сущностей
- Сущность не появилась. Что я делаю первым делом?
- Сначала факт: уходит ли config в правильный префикс и каким он выглядит. Это отделяет сетевые проблемы от кривого JSON за одну минуту. Смотрю сырые сообщения через mosquitto_sub -v
- Что делает пустой payload, опубликованный в discovery-топик с retain?
- Пустое сообщение затирает retained-конфиг, и HA убирает сущность. Это штатный способ снять с учёта старое или тестовое устройство. Удаляет сущность из Home Assistant