Свет на веранде я завёл в Home Assistant на первом же месяце с брокером: реле щёлкало от payload ON в командном топике — минимум, показанный в уроке про первую команду свету. Через полгода схема перестала устраивать: после рестарта HA панель забывала, горит ли веранда, слайдера яркости не существовало, а попытка «включи на треть» упиралась в отсутствие полутонов. Дописал до полной схемы и здесь разбираю её по частям.
У светильника три топика с разными ролями: command — сюда HA кладёт приказ, state — сюда плата отчитывается о факте, availability — статус связи из урока про завещание. Пока у светильника есть state-топик, HA живёт в не-optimistic режиме: панель рисует только то, что плата подтвердила, и рестарты брокера ей не страшны. Перепутаете направление — плата будет приказывать панели, а панель отчитываться плате; дом вежливо усмехнётся и перестанет работать.
Кстати об optimistic — я начинал именно с него, и первые месяцы меня всё устраивало: панель рисовала ответ мгновенно, не дожидаясь платы. Расплата проявилась, когда в схеме появился автомат по датчику движения: он гасил веранду с платы, панель об этом не знала и держала свой ON; кликал я вслепую, и примерно каждый десятый заход свет вёл себя не так, как рисовало приложение. Переезд на не-optimistic со state-топиком добавил к нажатию круг через брокера — на домашнем Wi-Fi двадцать-пятьдесят миллисекунд, глазом не отличить, — зато панель перестала врать: она рисует ровно то, что плата подтвердила, а спорить с реальностью остаётся только гнилому проводу.
Разберу payload — строки ON/OFF против JSON-объекта с яркостью — и callback, который обязан быть коротким: с длинным обработчиком соединение рвётся на ровном месте, и свет перестаёт слушаться в самый нужный вечер.
Command, state, availability
Command-топик слушает плата: client.subscribe("home/veranda/set") в ветке connect и strcmp по топику в callback. State-топик пишет плата после применения команды — тем же publish, каким едет телеметрия. Availability — удел брокера и connect: воля и birth из урока про last will живут там же, рядом с командами и состоянием. Направления потоков легко увидеть на живом доме: в мониторе брокера оба потока видны в реальном времени, и перепутанный топик бросается в глаза за минуту.
Payload команды — строки ON/OFF: сравнение strncmp(payload, "ON", 2) == 0, а поля payload_on/payload_off в конфиге HA позволяют заменить их на что угодно. Но одна строка не переносит яркость, поэтому светильник с диммером я перевёл на JSON: {"state":"ON","brightness":128} — один пакет переносит и состояние, и полутон. Структура JSON-полезной нагрузки и её разбор показаны в уроке про JSON-payload; границы между сырым текстом и JSON — в уроке про два формата.
У retain на этих топиках судьбы разные, и перепутать дорого. State публикую с retain=true: после рестарта HA заберёт последнее подтверждённое состояние прямо из брокера, ещё до первого касания панели, — тот же брокерский retained-кэш, что хранит availability. А вот command-топик retain не получает никогда: retained-приказ переживает и плату, и собственный смысл. Сценарий, пойманный на себе: вечером свет погашен, панель показывает OFF, а в командном топике с обеда лежит retained ON — ночью плата перезапустилась, подписалась и мгновенно приняла старую команду как новую; веранда вспыхнула в три часа, когда никому не нужно. С тех пор правило: retain — для фактов, не для приказов; случайно оставшийся retained-приказ стирается пустым payload в тот же топик.
Яркость в HA — шкала 0-255. На ESP32-ядре analogWrite принимает ровно 0-255 — шкалы совпали без математики. На ядре ESP8266 ШИМ живёт в диапазоне 0-1023, и значение от HA нужно протянуть через map(0, 255, 0, 1023), иначе максимум слайдера даст четверть накала. На классической Uno ШИМ только на пинах 3, 5, 6, 9, 10, 11 — мосфет светильника я вешаю на девятый. Плавный ход яркости, когда каждое значение надо прокручивать по таймеру, — в уроке про светильник с плавной яркостью.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const int PIN_LIGHT = 12; // мосфет светильникаint brightness = 0; // 0-255: применённая яркостьint stateOn = 0; // факт состояния для state-топика void publishState() { char msg[48]; snprintf(msg, sizeof(msg), "{\"state\":\"%s\",\"brightness\":%d}", stateOn == 1 ? "ON" : "OFF", brightness); client.publish("home/veranda/state", msg, true);} void mqttCallback(char* topic, byte* payload, unsigned int len) { // правило урока: сравнить, решить, один publish — и выйти if (strcmp(topic, "home/veranda/set") != 0) { return; } if (strncmp((char*)payload, "ON", 2) == 0) { stateOn = 1; brightness = 255; } else if (strncmp((char*)payload, "OFF", 3) == 0) { stateOn = 0; brightness = 0; } analogWrite(PIN_LIGHT, brightness); publishState(); // ответ в state: HA рисует факт, не надежду} void connectMqtt() { client.setServer(MQTT_SERVER, 1883); if (client.connect("veranda-light", "home/veranda/availability", 1, true, "offline")) { client.publish("home/veranda/availability", "online", true); client.subscribe("home/veranda/set"); publishState(); // панель узнаёт факт сразу после reconnect }} void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); connectMqtt();} void loop() { client.loop(); if (!client.connected()) { reconnectByTimer(); }}Callback обязан быть коротким
mqttCallback вызывается из client.loop(): пока он крутится, не тикает ни чтение сокета, ни keepalive-пинг. Обработчик с delay(5000) на каждое нажатие — пять секунд, в которые PINGREQ не ушёл; повторяйте это на оживлённой панели — и однажды тишина перевалит за полтора keepalive: брокер раздаст волю, соединение ляжет, свет перестанет слушаться. Симптом коварный: скетч жив, Serial печатает, а MQTT молчит по кругу переподключений — я искал такую болячку в чужом доме два вечера.
Моё правило: в callback — сравнить топик, разобрать payload, применить значение, один publish — и выйти; это единицы миллисекунд. Всё долгое — плавные переходы, ожидания, логику — уношу флагами в millis-цикл: callback ставит целевую яркость, loop ползёт к ней по таймеру. Приёмы маршрутизации нескольких топиков в одном callback разобраны в уроке про callback-маршрутизацию, а базу — метки времени вместо задержек — в уроке про millis вместо delay. Проверка за минуту: щёлкайте переключение в панели часто-часто — если зазоры loop растут, в callback прорвалась тяжёлая работа.
Подтверждение команды ответом в state — вторая половина дисциплины. Отчитываюсь фактом, а не эхом приказа: просят 300 при шкале 0-255 — применю 255 и скажу 255; валидация отвергла — state остаётся прежним. HA в не-optimistic режиме рисует только подтверждённое, и призрачные двойные нажатия исчезают: панель нажала — плата ответила — картинка совпала. Заодно я повторяю state после каждого reconnect: панель узнаёт факт сразу, не дожидаясь следующего переключения, а разбор чисел из потока пригождается, когда brightness приходит текстом.
Слайдер добавляет темпу: HA шлёт brightness на каждое движение пальца, перетаскивание от края до края — до пятнадцати JSON в секунду. Отвечать state на каждый тик — удвоить поток: команда плюс ответ, три десятка пакетов в секунду на один слайдер, а панель между тем мигает фактами, устаревшими на пол-оборота ручки. Мой развяз: значение применяю сразу — analogWrite дёшев, — а публикацию state откладываю до паузы в двести миллисекунд после последнего тика: поток стих, в state уезжает финальное число, один пакет вместо пятнадцати. Debounce по millis, три строки кода; без него диммер работает, но брокер и журнал за одно движение руки пачкаются сотней промежуточных фактов.
Flash, transition, цвет: чем платит богатый payload
Схема light в HA умеет больше, чем state и brightness, и цену полезно знать до того, как попросишь. Поле flash приходит в том же JSON: {"state":"ON","flash":"short"} — короткое мигание для привлечения взгляда; transition — плавный переход в секундах: HA пришлёт {"transition":3} и будет ждать, что яркость доползёт сама, — ползти она будет вашим millis-циклом из урока про плавную яркость, брокер таймер за вас не заведёт. Цветная лампа тянет либо вложенный объект {"color":{"r":255,"g":128,"b":0}}, либо color_temp в миредах — шкала 153-500 от холодного к тёплому. Объект при этом подрастает до восьмидесяти-девяноста байт.
Мой вердикт по бюджету: веранде хватило пары state/brightness — два поля, около сорока байт, разбор на strncmp; цвет достался только настольной лампе, где без него никак. Там же пересчитал буфер: JSON с цветом, retained state и воля в CONNECT при дефолтных 256 байтах запаса не оставляли — поднял MQTT_MAX_PACKET_SIZE до 512 и перестал считать каждый байт. Дисциплина при этом та же: незнакомые ключи в payload игнорирую, подтверждаю только применённое — HA описывает светильник под ваши возможности в конфиге, а не наоборот.
Про скорость подтверждения — пара честных чисел, чтобы вы не переусложнили. Круг команда-плата-state-панель через локальный брокер на моём доме — тридцать-шестьдесят миллисекунд: слайдер ощущается прямым, и не-optimistic ничего не портит. За городом, где брокер за VPN-стыком с RTT 150-200 миллисекунд, тот же круг растягивается до полусекунды — панель отстаёт заметно, и здесь я сознательно включал optimistic, принимая ложные показания как плату за отзывчивость, а истину оставлял state-топику, с которым панель сверяется после минутной тишины. Выбор зависит от задержки, а не от догмы: померьте круг в своём доме — mosquitto_sub с метками времени покажет его до миллисекунды — и решайте по числу.
| Топик | Кто пишет | Payload | Зачем |
|---|---|---|---|
| home/veranda/set | Home Assistant | ON / OFF или JSON с brightness | команда: плата слушает и применяет |
| home/veranda/state | плата | JSON: state и фактическая яркость | подтверждение фактом для не-optimistic панели |
| home/veranda/availability | брокер и connect | online / offline с retain | статус связи из урока про завещание |
| home/veranda/state с retain | плата | последний факт живёт в брокере | HA после рестарта читает состояние до первого касания панели |
| command с retain=true | анти-паттерн | старый приказ ждёт в брокере | перезапущенная плата примет вчерашний ON как новый — вспышка в три часа |
- 1. Куда Home Assistant кладёт приказ для светильника?
- 2. Почему state-подтверждение посылается фактом, а не эхом команды?
- 3. HA слайдер прислал brightness 300. Что делает скетч перед analogWrite?
- 4. Что рвёт соединение при длинном callback?
- 5. Светильник переподключился после обрыва. Что делает скетч сразу после connect?
- Три топика светильника: command для приказов HA, state для факта от платы, availability для статуса связи с retain.
- Строки ON/OFF — для реле; JSON с полями state и brightness — для диммера: один пакет переносит состояние и полутон.
- Яркость HA — 0-255; на ESP32-ядре analogWrite совпадает по шкале, на ESP8266 — map в 0-1023 и constrain перед применением.
- Подтверждение — фактом, не эхом: не-optimistic панель рисует только то, что плата применила; после reconnect — birth и повтор state.
- Callback — единицы миллисекунд: сравнить, применить, один publish; всё долгое — флагами в millis-цикл, иначе воля и разрыв.
Частые вопросы по теме урока
- Куда Home Assistant кладёт приказ для светильника?
- Команда едет в command-топик, состояние возвращается в state, статус связи живёт в availability. Перепутанные направления — самый частый дебют в схеме. В command-топик: home/veranda/set
- Почему state-подтверждение посылается фактом, а не эхом команды?
- Не-optimistic панель рисует только подтверждённое. Просят 300 при шкале 0-255 — применяю 255 и сообщаю 255: факт, а не надежда. Панель должна показать применённое значение, даже если приказ пришлось ограничить
- HA слайдер прислал brightness 300. Что делает скетч перед analogWrite?
- Автоматы и голосовые помощники не обязаны знать вашу шкалу: constrain перед применением и факт в state — панель и плата всегда согласны. constrain до 0-255 и подтверждает 255
- Что рвёт соединение при длинном callback?
- Callback крутится внутри client.loop(): пока он занят, не тикают ни пинг, ни чтение сокета. Перевалило за полтора keepalive — воля и разрыв. client.loop() стоит: keepalive-пинг не уходит, брокер отдаёт волю
- Светильник переподключился после обрыва. Что делает скетч сразу после connect?
- Birth оживляет availability, повтор state возвращает панель к факту после рестарта. Без этого свет горит, а панель уверена в обратном. Публикует birth и повторяет state — панель узнаёт факт