Второй переключатель теплицы — вентилятор — я заводил в Home Assistant уже по накатанной: скопировал блок сборки payload от насоса, поменял имя, поправил топики. Почти все. Через два дня заметил, что вентилятор и насос щёлкают синхронно: в копии уцелел command_topic насоса, и обе карточки дёргали одну линию. Час я искал виноватого в проводке, а виноват оказался сам подход — паспорт устройства был склеен строками оператором +, и каждая новая сущность несла свежую порцию кавычек, запятых и склеек, в которых копипасту легко уцелеть. После этого случая паспорта собирает документ, а строку из него печатает serializeJson.
Паспорт для температурного сенсора уже разобран в разделе MQTT — урок про MQTT Discovery показывает анатомию топика, retain и отладку через mosquitto_sub, а команда из умного дома приезжает на плату так, как в материале про свет из Home Assistant. Здесь другой срез: switch со своими payload_on и payload_off, генерация конфиг-топика из кода и две грабли, до которых тот урок не дошёл, — плавающий unique_id и буфер, молча режущий длинный паспорт.
План: поля паспорта переключателя и что каждое включает; когда конфиг уезжает — один раз при старте и снова после каждого reconnect, но никогда из loop; чем грозит unique_id, собранный из переменных вещей; и таблица, где склейка строк ещё допустима, а где документ дешевле. В конце — симулятор с виртуальным брокером: паспорт видно целиком, до последней кавычки.
Паспорт переключателя: топик и поля
Топик переключателя собирается по схеме homeassistant/<тип>/<идентификатор>/config. Для насоса это homeassistant/switch/nasos01/config: homeassistant — служебный префикс, который интеграция слушает всегда; switch — тип сущности, от него зависит карточка с тумблером; nasos01 — идентификатор устройства, его придумываю я; config — фиксированное слово, помечающее описание, а не данные. Перепутанный слой не ругается: брокер примет что угодно, а Home Assistant молча проигнорирует. Топик состояния у того же насоса другой — dacha/nasos/state, и это стоит держать в голове: паспорт описывает адреса, но сам в них не живёт.
Внутри payload — JSON, и минимальный набор полей у switch такой: name — подпись карточки; unique_id — имя сущности в масштабе дома; state_topic — откуда Home Assistant читает состояние; command_topic — куда шлёт команды; payload_on и payload_off — строки, которые улетят на command_topic при щелчке тумблера. Эти две строки обязаны совпадать с тем, что скетч ловит в callback, посимвольно: on и ON для платы — разные команды. Значения придумываю я сам, и это свобода с обратной стороной: договорились слать 1 и 0 — значит, и callback сравнивает с 1 и 0, а не с on и off из соседнего скетча. Если state_topic задан, Home Assistant ждёт подтверждения, и тумблер возвращается назад, пока плата не опубликует состояние; уберите state_topic — переключатель станет optimistic и будет верить щелчку на слово.
Собираю паспорт документом: JsonDocument, поля по имени, serializeJson — и кавычка потеряться не может, потому что её никто не пишет руками. Тот же приём я показывал в уроке про serializeJson на телеметрии; паспорт отличается жизнью: печатается один раз при подключении, а не раз в минуту, и едет с retain, чтобы Home Assistant перечитал его после собственного рестарта.
#include <WiFi.h>#include <PubSubClient.h>#include <ArduinoJson.h> WiFiClient net;PubSubClient client(net); const char* ID = "esp32-nasos01";String T_CFG = "homeassistant/switch/nasos01/config";String T_ST = "dacha/nasos/state";String T_CMD = "dacha/nasos/cmd"; bool stan = false;unsigned long lastTry = 0; void otpravitStan() { client.publish(T_ST.c_str(), stan ? "on" : "off");} // паспорт собирает документ: поля по имени, кавычки ставит serializeJsonvoid announce() { JsonDocument doc; // v6: DynamicJsonDocument(512) doc; doc["name"] = "Nasos teplicy"; doc["unique_id"] = "nasos01_switch"; doc["state_topic"] = T_ST; doc["command_topic"] = T_CMD; doc["payload_on"] = "on"; doc["payload_off"] = "off"; String payload = ""; serializeJson(doc, payload); Serial.print("discovery: "); Serial.print(measureJson(doc)); Serial.println(" bayt"); Serial.println(payload); if (client.publish(T_CFG.c_str(), payload.c_str(), true)) { Serial.println("config ushel s retain"); } else { Serial.println("publish vernul 0: payload ne vlez v bufer"); } client.subscribe(T_CMD.c_str());} void naKomandu(char* topic, byte* body, unsigned int len) { String msg = ""; for (unsigned int i = 0; i < len; i++) msg += (char)body[i]; if (msg == "on") { stan = true; otpravitStan(); } if (msg == "off") { stan = false; otpravitStan(); }} void setup() { Serial.begin(115200); WiFi.begin("HomeNet", "parol"); while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); } Serial.println(); client.setServer("192.168.1.50", 1883); client.setCallback(naKomandu); client.setBufferSize(512); // буфер по умолчанию 256: паспорт длиннее announce(); // первый паспорт — сразу после connect} void loop() { if (!client.connected() && millis() - lastTry > 5000) { lastTry = millis(); if (client.connect(ID)) announce(); // и после каждого reconnect } client.loop();}measureJson перед публикацией — мой предохранитель. Паспорт из шести полей весит около 230 байт; стоит добавить device с именем и моделью, и цифра переползает за 256 — потолок буфера PubSubClient по умолчанию. Дальше начинается самая тихая грабль протокола: publish возвращает 0, соединение живо, журнал пуст, сущность не появляется. Лечится одной строкой setBufferSize(512) до первого connect, о чём подробнее в уроке про буфер PubSubClient; цена — та же ОЗУ, и на Uno паспорт со вложенным device уже приходится резать. Печать payload в Serial при каждом announce приучила меня видеть паспорт глазами, а не надеяться на брокер.
Конфиг уезжает один раз при старте и снова после каждого успешного connect — и никогда из loop. Операция копеечная: двести с лишним байт раз в часы и дни, зато сценарий «брокер переезжал, база чистая» перестаёт быть катастрофой — устройство само зарегистрируется в первые секунды. Retain в третьем аргументе publish держит паспорт в памяти брокера: Home Assistant после собственного рестарта читает его из retained, не дожидаясь свежей публикации. Один шаг до старта: убедитесь, что интеграция MQTT вообще слушает discovery — Настройки → Устройства и службы; выключатель discovery в интеграции глушит все паспорта разом, и поиск тогда начинается не с прошивки, а с галочки на сервере. Каркас переподключения без delay собран в уроке про reconnect по millis, а почему подписки и announce живут внутри reconnect, а не в setup, — в материале про восстановление статусов.
Optimistic-режим я сознательно оставил за кадром кода. Уберите state_topic из паспорта — тумблер в интерфейсе станет переключаться мгновенно, на слово, без подтверждения от платы. С state_topic — только после её публикации. Для насоса я выбираю подтверждение: щелчок, который вернулся назад, честнее мгновенного, совравшего. Для вентилятора с плавным пуском optimistic даже удобнее: задержка полсекунды на раскрутку заметна глазу, а интерфейс, который врал полсекунды, никто не назовёт честным.
unique_id: раз и навсегда
unique_id — единственное поле паспорта, которое обязано пережить все прошивки. Собираю его из роли, типа и номера: nasos01_switch, vent01_switch, boiler02_switch. Идентификатор в топике и unique_id связаны, но не равны: первый адресует паспорт в брокере, второй — сущность в масштабе дома. Пока unique_id постоянен, Home Assistant при каждом переподключении узнаёт того же жильца: история, автоматизации и место в панели остаются при нём.
Грабля бьёт по тем, кто собирает unique_id из переменных вещей: MAC-адреса, millis, счётчика загрузок. MAC сменился после переезда роутера — и в доме появились двойники: nasos_3c71_switch рядом с nasos_5e22_switch, оба живые, история у старого. Вычищаются клоны только руками: пустой payload в config-топик двойника удаляет сущность штатно, тем же механизмом, которым я убираю тестовые карточки. Постоянный unique_id из константы стоит ноль усилий и экономит вечер.
Есть у unique_id и приятная сторона, о которой узнаёшь при сороковой сущности: из него Home Assistant строит имя сущности в автоматизациях — switch.nasos01_switch, датчик с ним получает object_id из того же поля. Внятная схема имён экономит минуты в списке из тридцати карточек и делает YAML-автоматизации читаемыми: switch.nasos01_switch ничего не говорит против switch.rele_4, по которому через полгода придётся гадать.
| Способ собрать payload | Цена и риск | Когда беру |
|---|---|---|
| Константа в PROGMEM целиком | нуль ОЗУ на сборку, но правка = прошивка; опечатка в кавычках не видна до отладки | паспорт, который не менялся годами |
| Склейка String оператором + | кавычки и запятые пишутся руками; на копипасте уцелевший command_topic не виден глазом | одноразовый эксперимент на коленке |
| ArduinoJson и serializeJson | пул под документ, зато поля по имени, печать экранирует, measureJson знает размер заранее | дефолт для всех моих устройств |
| Паспорт в YAML на сервере | интерфейсу всё равно, но каждое новое устройство — правка файла и рестарт HA | когда устройство не умеет в discovery вовсе |
Движок книги эмулирует брокера: client.publish печатает топик и payload в журнал MQTT, и паспорт видно целиком — строки журнала повторяют то, что на компьютере с брокером показывает mosquitto_sub. Единственная подмена — в скетче симулятора payload получает одноаргументная форма String payload = serializeJson(doc);, особенность движка; на настоящей плате строка та же, но с двумя аргументами, как в код-блоке выше. setBufferSize движок не исполняет и не нужен: виртуальный брокер молчаливых лимитов не держит.
Что попробовать. Кнопка D2 — ручное переключение на месте: реле D7 и светодиод D12 меняются, состояние уезжает в state_topic. Команды из умного дома моделирует монитор: шлите on или off — ровно то, что Home Assistant кладёт на command_topic; строки совпадают с payload_on и payload_off из паспорта. Поменяйте в скетче payload_on на «1» и шлите on: команда перестанет совпадать с ожиданиями скетча — так выглядит несовпадение в живом доме.
Раз в десять секунд в журнал уезжает строка состояния — так выглядит state_topic в отрыве от discovery. Карточка в живом интерфейсе берёт именно её, и паспорт нужен лишь один раз: состояния едут постоянно, описание — при старте и reconnect. В журнале это видно наглядно: одна строка с homeassistant/switch/…/config и колонка строк dacha/nasos/state.
- 1. Что улетит на command_topic, когда тумблер в Home Assistant переводят вниз?
- 2. Паспорт весит 280 байт, setBufferSize не вызван. Что произойдёт после publish?
- 3. Почему конфиг публикуют после каждого reconnect, а не только в setup?
- 4. unique_id собран из MAC-адреса, роутер выдал плате новый. Что в Home Assistant?
- 5. Как штатно удалить сущность из Home Assistant?
- Топик паспорта: homeassistant/switch/<идентификатор>/config; данные едут в state_topic, команды — на command_topic
- payload_on и payload_off — строки команд; совпадают с тем, что скетч ловит в callback, посимвольно
- Паспорт собирает JsonDocument и печатает serializeJson: кавычки не пишутся руками, measureJson знает размер до публикации
- Публикация — один раз при старте и после каждого reconnect, с retain; из loop — никогда
- unique_id — константа на все прошивки; плавающий id плодит двойников, пустой payload в config — штатное удаление
Частые вопросы по теме урока
- Что улетит на command_topic, когда тумблер в Home Assistant переводят вниз?
- Home Assistant шлёт ровно то, что написано в payload_on и payload_off; скетч в callback сверяет msg с теми же строками посимвольно. Строку payload_off из паспорта — обычно off
- Паспорт весит 280 байт, setBufferSize не вызван. Что произойдёт после publish?
- Буфер PubSubClient по умолчанию 256; длинное сообщение молча не уходит. Лечение — setBufferSize(512) до первого connect. publish вернёт 0, соединение живо, сущность не появится
- Почему конфиг публикуют после каждого reconnect, а не только в setup?
- Паспорт весит двести байт и случается раз в часы: цена нулевая, а переезд брокера или чистая база перестают быть аварией. Брокер с чистой базой после переезда узнает устройство в первые секунды
- unique_id собран из MAC-адреса, роутер выдал плате новый. Что в Home Assistant?
- unique_id — единственная связь сущности с историей и автоматизациями; плавающий id плодит клоны без единого сообщения в журнале. Появится двойник: новая сущность с новым id, история останется у старой
- Как штатно удалить сущность из Home Assistant?
- Пустой payload затирает retained-сообщение паспорта, и карточка исчезает — тем же механизмом, которым она создавалась. Опубликовать пустой payload в тот же config-топик