Ползунок яркости в Home Assistant — самая обманчивая деталь интерфейса: ведёшь его плавно, а свет на реальной плате прыгает одним щелчком. Я собрал первый диммер на ESP32 ровно так: в callback пришла строка «128» — и я тут же записал 128 в analogWrite. Лента щёлкала с нуля на половину за одну миллисекунду, и вся плавность ползунка оставалась жить в браузере телефона. Гости таких вещей не замечают, а я замечал каждый вечер и злился.
Настоящий диммер устроен иначе: число в payload — это не приказ «сделай мгновенно», а целевая точка, к которой свет идёт сам. Фирменные диммеры делают ровно это: получают цель и выкатывают яркость с шагом, поэтому их ход смягчён, даже если ползунок дёрнуть от края до края. Ниже — та же логика на ESP32 в пару десятков строк: payload «128» на cmd/light, плавный переход шагом 2 единицы каждые 15 мс и отчёт state/light строго по достижению цели.
Три вещи, которые надо разложить до скетча: в каком виде приходит число и на каком топике, как перейти от полученного к плавному ходу внутри кооперативного цикла и когда именно отчитываться — на каждом шаге или один раз в конце. Конвейер «команда — действие — отчёт» в целом я собирал в уроке про свет из Home Assistant, там же реле и физическая кнопка; здесь у нагрузки вместо щелчка появляется шкала 0-255, и это меняет все три звена сразу.
Договор яркости: 0-255 и три топика
В платформе mqtt light у Home Assistant, помимо command_topic для включения и выключения, есть пары для яркости: brightness_command_topic, куда прилетают числа, и brightness_state_topic, откуда ползунок читает положение. Шкала по умолчанию — 0-255: 128 — это половина не «по смыслу», а по шкале, до которой ползунок доезжает ровно в середине хода. Ключом brightness_scale шкалу можно перенастроить хоть на 1000, но каждый шаг отступления от 255 стоит отдельного теста: автоматизации и чужие скрипты упрямо пишут 255 там, где ждёшь своё.
Грабля, на которую в сообществе жалуются годами: интерфейс рисует проценты, а по проводу летят 0-255, и кто-то в цепочке услужливо делит на 2,55. Половина яркости превращается в 50 из 255 — пятую часть, и человек за ползунком уверен, что плате не хватает тока. Схему я не усложняю: слайдер шлёт то, что обещано документацией платформы, плата получает число целиком, и никаких пересчётов между ними нет.
И payload — строка. «128» из брокера — это три символа, а не байт: MQTT не знает про типы, PubSubClient отдаёт байты с длиной, и превращать их в число — моя работа на плате. У String есть toInt, у ESP32-обвязок — свои помощники вроде payloadToInt, но все они молча возвращают 0 на мусоре, и к этой тишине я вернусь после скетча. Механику ШИМ, из которой собирается сам ход яркости, я разбирал отдельно — от заполнения 0-255 до границ частоты — в уроке про analogWrite.
#include <PubSubClient.h> WiFiClient net;PubSubClient client(net);const char* T_CMD = "cmd/light";const char* T_STATE = "state/light";const int LED = 9; // на настоящей Uno ШИМ только на 3, 5, 6, 9, 10, 11const int SHAG = 2; // единиц яркости за шагconst unsigned long PAUZA = 15; // мс между шагами int tek = 0; // текущая яркостьint target = 0; // целевая из последней командыbool needReport = false;unsigned long lastStep = 0; void callback(char* topic, byte* payload, unsigned int length) { String p = ""; for (unsigned int i = 0; i < length; i++) { p += (char)payload[i]; } if (String(topic) != T_CMD) { return; } int pl = p.toInt(); // мусор вернёт 0 — проверка строк ниже if (pl < 0) { pl = 0; } if (pl > 255) { pl = 255; } target = pl; needReport = true; Serial.print("target="); Serial.println(target);} void setup() { Serial.begin(9600); pinMode(LED, OUTPUT); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) { delay(300); } client.setServer("192.168.1.50", 1883); while (!client.connect("esp32-svetilnik")) { delay(2000); } client.setCallback(callback); client.subscribe(T_CMD);} void loop() { client.loop(); unsigned long now = millis(); if (tek != target && now - lastStep >= PAUZA) { lastStep = now; if (tek < target) { tek += SHAG; if (tek > target) { tek = target; } } else { tek -= SHAG; if (tek < target) { tek = target; } } analogWrite(LED, tek); } if (needReport && tek == target) { client.publish(T_STATE, String(tek)); Serial.print("state/light="); Serial.println(tek); needReport = false; }}Callback здесь занимает пять строк: распарсить, зажать в 0-255, запомнить цель, поставить флаг. Ни одного analogWrite — исполнение живёт в loop, и причина конкретная: callback вызывается изнутри client.loop(), и долгая работа там стоит пропущенных пингов и разрывов. Дисциплину «в callback только флаги» я выстраивал в уроке про кооперативный цикл вместе с мигалками — светильник подчиняется тем же правилам.
Ход держится на двух константах: шаг 2 единицы и пауза 15 мс между шагами. От нуля до 255 — ровно 128 шагов, полный переход занимает 128 × 15 = 1920 мс, около двух секунд: фирменная плавность диммера получается без таймеров и прерываний, просто дисциплинированный шаг. Хочется медленнее — уменьшай шаг; быстрее — увеличивай; но ниже шага 1 не ныряй: с шагом 1 и той же паузой полный ход растянется на 3,8 секунды, ползунок будет обгонять свет на экране, и это раздражает сильнее резкого щелчка.
Отчёт публикуется один раз — по достижению. Соблазн рапортовать каждый шаг понятен: 128 шагов — 128 публикаций, и ползунок в HA поедет вместе со светом, красиво. Но каждая публикация — пакет в брокер, строка в логе и перерисовка интерфейса, а человек читает состояние раз в секунду, не чаще. Мой компромисс: state/light уходит один раз в конце перехода; если для проекта нужен живой прогресс — публикую не чаще раза в 500 мс и только пока свет реально едет.
| Payload на cmd/light | Цель | Переход | Что увидит HA |
|---|---|---|---|
| 0 | погаснуть | вниз с текущего уровня, ~1,9 с с полного | ползунок в ноль после state/light=0 |
| 128 | половина шкалы | 128 шагов с нуля или меньше с середины | ползунок в середину |
| 255 | максимум | полный ход ~1,9 с | ползунок до упора |
| 999 | зажата до 255 | ход до максимума, лишнее отброшено | ползунок до упора, без сюрпризов |
Молчаливый ноль toInt — грабля номер один этого скетча. Строка «128» с пробелом в конце парсится нормально, а вот «full», обрыв «1» посреди сетевого шума или письмо от чужого издателя возвращают 0 — и свет честно уезжает гаснуть, хотя человек двигал ползунок на середину. Лечение в две строки перед записью цели: в payload должен быть хотя бы один символ, и все они должны быть цифрами; нецифровая строка игнорируется с записью в Serial, свет остаётся на месте.
Второе место, где я спотыкался физически, — пин. На настоящей Uno ШИМ есть только на пинах с тильдой — 3, 5, 6, 9, 10 и 11, а analogWrite на любом другом молча вырождается в digitalWrite: до половины шкалы ноль, после — полный накал, и фейдер превращается в щелчок на середине хода. В симуляторе ниже светодиод стоит на 12-м пине, потому что макетке без разницы и движок честно рисует яркость на любой ножке; на реальной плате светодиод переехал бы на 9-й или 11-й. Про сам приём плавного хода без delay у меня есть отдельный урок про фейдер — там он показан на кнопке, здесь же его заказчиком выступает брокер.
Симулятор: команда set → плавный ход
В симуляторе путь команды сокращён честно: строку из монитора скетч разбирает тем же кодом, каким на реальной плате занимался бы callback, — пробел отделяет топик от payload, проверка цифр отсекает мусор, зажим в 0-255 ставит цель. Дальше всё как на плате: шаг 2 за 15 мс, светодиод на D12 выезжает на цель, и в момент достижения в MQTT-лог уходит state/light. Разбор входящей строки по пробелу — тот же приём, что в командном парсере Serial, навык переносится один в один.
И оговорка про пин ещё раз, теперь с числами: движок рисует ШИМ на любом пине, но на реальной Uno analogWrite(12, 128) даст не половину накала, а щелчок из нуля в единицу — у 12-го пина нет тильды. Для макетки это неважно, для ленты на балконе важно: туда ставь 9, 10 или 11-й. Команды ON и OFF в этом скетче не заведены намеренно: настоящий светильник в HA держит три топика сразу, и «on» там лишь вспомнил бы последнюю яркость — расширение на три строки оставляю как домашку.
- Payload — целевая точка, а не приказ мгновенно: callback зажимает число в 0-255 и ставит target, ход делает loop
- Шаг 2 единицы каждые 15 мс: 128 шагов, полный ход 0-255 около 1,9 с; медленнее — меньше шаг, быстрее — больше
- state/light уходит один раз по достижению: 128 публикаций за переход — спам, а не телеметрия
- toInt молча возвращает 0 на нецифровом payload: строка «full» без проверки гасит свет вместо середины
- ШИМ на Uno только на пинах 3, 5, 6, 9, 10, 11: analogWrite на остальных вырождается в digitalWrite
- 1. В каком диапазоне шлёт значения ползунок яркости платформы mqtt light по умолчанию?
- 2. Что именно приходит в brightness_command_topic, когда ползунок стоит на середине?
- 3. Шаг 2 единицы, пауза 15 мс. Сколько идёт полный переход 0 → 255?
- 4. Почему отчёт state/light публикуется один раз, по достижению, а не на каждом шаге?
- 5. analogWrite(12, 128) на настоящей Uno — что реально будет на пине?
Частые вопросы по теме урока
- В каком диапазоне шлёт значения ползунок яркости платформы mqtt light по умолчанию?
- Шкала по умолчанию 0-255 и меняется ключом brightness_scale. 1023 — мир analogRead, 4095 — ADC ESP32; к ползунку HA они отношения не имеют. 0-255 — шкала brightness_scale по умолчанию
- Что именно приходит в brightness_command_topic, когда ползунок стоит на середине?
- MQTT не знает типов: payload — байты, PubSubClient отдаёт их с длиной, и превращать в число обязан скетч. Дробных значений в этой схеме не бывает. строка из трёх символов: 1, 2 и 8
- Шаг 2 единицы, пауза 15 мс. Сколько идёт полный переход 0 → 255?
- От 0 до 255 — 128 шагов: 128 × 15 = 1920 мс. 3,8 с получится при шаге 1, а не 2. около 1,9 с: 128 шагов по 15 мс
- Почему отчёт state/light публикуется один раз, по достижению, а не на каждом шаге?
- Публикации из loop законны — вопрос меры: 128 пакетов на переход захламляют лог и дёргают интерфейс. Хочется прогресса — прореживай до раза в 500 мс. шагов 128, и каждый кадр — пакет в брокер и перерисовка ползунка ради человека, который читает состояние раз в секунду
- analogWrite(12, 128) на настоящей Uno — что реально будет на пине?
- ШИМ-пины Uno — 3, 5, 6, 9, 10, 11. На остальных analogWrite молча становится digitalWrite: заполнение до 127 — ноль, с 128 — единица. вырожденный digitalWrite: у 12-го пина нет тильды, с 128 — полный накал