Кнопка звонка у калитки прожила у меня неделю в первом варианте скетча: loop опрашивал пин и каждые двести миллисекунд публиковал door/press со значением «нажата» или «нет». Брокер честно раздавал пять пакетов в секунду, сценарий в Home Assistant срабатывал на каждый — звонок перезванивал сам себя, журнал рос на мегабайты в сутки. Убрал период — стало хуже: публикации ушли, а короткое нажатие, попавшее между опросами, потерялось, и в дверь звонили в пустоту.
Правильная постановка: кнопка — издатель событий, а не состояний. Событие — «нажали один раз», оно живёт на фронте изменения состояния и публикуется ровно один раз. Состояние — «как сейчас», оно живёт в retained-пакете и обновляется при каждом изменении; механика retain разобрана в уроке про retained-статусы. Перепутать дорого в обе стороны: событие, разосланное как состояние, панель повторяет бесконечно; состояние, отправленное как событие, теряется при первом же перезапуске панели.
Разберём фронт и антидребезг — коротко, потому что подробно эти темы живут в соседних разделах, — потом буфер на одно событие для обрыва связи и выбор между QoS 0 и QoS 1 с его скрытой ценой. В конце — готовый скетч звонка с публикацией на фронте нажатия.
Фронт, а не уровень
Фронт — переход из одного состояния в другое. Ловится он просто: читаем пин, сравниваем с прошлым чтением, отличие — вот и фронт. Сырое чтение фронт врёт: механический контакт при замыкании дребезжит, выдавая десятки переходов за пять-двадцать миллисекунд. Антидребезг: зафиксировав изменение, выжидаем 25-50 мс и перечитываем — устоялось, значит приняли. У моей калитки хватило тридцати миллисекунд, у дешёвых кнопок бывало и пятьдесят. Базовый приём разобран в уроке про антидребезг кнопки, четыре способа с таймингами — в уроке про антидребезг без delay; здесь не повторяю.
Что публиковать на фронтах — вопрос дизайна топика, и решаю я его так. Звонок, сброс будильника, «нажали» — публикуем на фронте нажатия и только там: событие мгновенное, повторов нет. Тумблер «режим гостей» — публикует своё состояние на обоих фронтах с retain: панель, подключившись позже, получит актуальное значение сразу. Удержание кнопки три секунды — нужны оба фронта и таймер между ними: на нажатии запускаем отсчёт, на отпускании сверяем; автомат для такого случая собран в уроке про кнопку-автомат.
Почему не по циклу: брокер раздаёт каждый пакет всем подписчикам, панель перерисовывается, сценарии срабатывают, лог набухает. Пять пакетов в секунду от одной кнопки — сто восемьдесят тысяч в сутки: сеть переживёт, журнал и здравый смысл нет. Регулярной телеметрии — температуре, влажности — публикация по millis из урока про публикацию показаний и нужна: там показания, там цикл уместен. У кнопки события, а не показания.
Порядок вызовов в loop — деталь, на которой я обжигался. client.loop() должен крутиться каждый цикл: без него входящие пакеты лежат в сокете необработанными, keepalive протекает мимо, а PUBACK чужих пакетов задерживается. Опрос кнопки я ставлю ПОСЛЕ client.loop(): публикация с qos=1 внутри себя ждёт подтверждение, и если она встала на таймаут, фронт, прижатый в хвост цикла, просто дождётся своей очереди — потерянных нажатий нет. Инверсия порядка живёт месяцами и выстреливает редким «кнопка иногда двоит» — тем самым классом, который ищется по ночам.
Светодиод-квитанция — копеечная деталь скетча, которая сэкономила мне две поездки к калитке. LED горит, пока событие сидит в буфере: подошёл к плате — и без монитора порта видно, что звонок не ушёл. На плате в корпусе я вывожу такой же светодиод на корпус: «событие ждёт» — единственный статус, который стоит лампочки и гвоздя. Тушится он только после PUBACK, так что лампочка врёт ровно столько, сколько врёт связь.
#include <WiFi.h>#include <PubSubClient.h> const char* WIFI_SSID = "HomeNet";const char* WIFI_PASS = "12345678";const char* BROKER = "192.168.1.50"; const int PIN_BTN = 2; // кнопка к GND, INPUT_PULLUPconst int PIN_LED = 12; // горит: событие ждёт доставки WiFiClient net;PubSubClient client(net); int stable = HIGH; // последнее подтверждённое состояниеint lastRaw = HIGH;unsigned long bounceAt = 0;bool armed = false;bool pending = false; // буфер одного событияunsigned long reconnectAt = 0; void setup() { Serial.begin(115200); pinMode(PIN_BTN, INPUT_PULLUP); pinMode(PIN_LED, OUTPUT); WiFi.begin(WIFI_SSID, WIFI_PASS); client.setServer(BROKER, 1883);} void loop() { if (WiFi.status() != WL_CONNECTED) { delay(300); return; } if (!client.connected() && millis() - reconnectAt > 3000) { reconnectAt = millis(); if (client.connect("zvonok-kalitka") && pending) { client.publish("door/press", "1", false, 1); // сначала буфер pending = false; digitalWrite(PIN_LED, LOW); Serial.println("[MQTT] из буфера: door/press"); } } client.loop(); int raw = digitalRead(PIN_BTN); if (raw != lastRaw) { lastRaw = raw; bounceAt = millis(); armed = true; } if (armed && millis() - bounceAt >= 30) { armed = false; if (raw != stable) { stable = raw; if (stable == LOW) { // фронт нажатия if (client.connected() && client.publish("door/press", "1", false, 1)) { Serial.println("[MQTT] door/press qos=1, PUBACK получен"); } else { pending = true; // брокера нет: запомнили digitalWrite(PIN_LED, HIGH); Serial.println("[btn] брокера нет, событие в буфер"); } } } }}Две строки стоят отдельного взгляда. Первая — armed: фронт фиксируется на сыром изменении пина, а принимается только после тридцати миллисекунд тишины; без этого дребезг дал бы пять-десять публикаций на одно нажатие, и QoS 1 честно доставил бы все десять. Вторая — pending: ровно одна ячейка. Нажали, пока брокера не было, — событие не потерялось, LED горит, и после reconnect первым уезжает буфер, обычный цикл — потом.
Почему одна ячейка, а не очередь: для звонка и сброса будильника актуально последнее нажатие — десять пропущенных звонков, пока вас не было дома, равны одному. Россыпь событий, где значимо каждое — импульсы счётчика воды, открытия дверей, — требует кольцевого буфера на десятки записей; такой буфер с арифметикой памяти я собирал в уроке про офлайн-буфер.
QoS 0 против QoS 1: цена подтверждения
QoS 0 — пакет уехал в сеть и забыт: ни подтверждения, ни повтора. Обрыв связи в момент нажатия съест событие молча, и звонок просто не прозвенит. QoS 1 добавляет рукопожатие: брокер отвечает PUBACK, клиент без подтверждения повторяет пакет. Для события кнопки цена вопроса — одна публикация в месяц, и я плачу QoS 1: потерянный звонок дороже лишних четырнадцати байтов трафика.
У QoS 1 в PubSubClient есть скрытая цена: publish с qos=1 ждёт PUBACK синхронно, внутри вызова, и блокирует loop на время таймаута сокета. Нажали кнопку в момент обрыва — скетч замрёт на несколько секунд, опрос пина встанет. Для звонка не смертельно, для реле с таймингами — фатально. Мой выбор: события — QoS 1 и короткий payload, чтобы блокировка укладывалась в секунду; телеметрия и статусы — QoS 0. Механика подтверждений по шагам разобрана в уроке про QoS 1 у реле, общая цена уровней — в уроке про цену QoS.
Таймаут ожидания настраивается: setSocketTimeout(секунды) у PubSubClient, по умолчанию там пятнадцать секунд — целая вечность для устройства, где по этим же пятнадцати секундам молчит датчик. Я ставлю две-три секунды: PUBACK на живом брокере приезжает за десятки миллисекунд, а на мёртвом лучше упасть быстро и пойти по ветке буфера, чем висеть. Проверьте свой дефолт перед продакшном — половина «зависших» кнопок лечится одной строкой.
И про повторную доставку: QoS 1 гарантирует «минимум один раз», значит событие может доехать дважды — обрыв случился после отправки, до получения PUBACK. Сценарий «звонок» от повторного пакета перезвонит дважды: неприятно, не страшно. Для команд с побочным эффектом — «открыть замок» — дедупликация на стороне подписчика обязательна: идентификатор события в payload и проверка «а не видел ли я его раньше». Контракт придумывается до первого повторного звонка, а не после него.
| Случай | Что публикуем | Фронты | Мой QoS |
|---|---|---|---|
| Звонок, сброс будильника | событие door/press без retain | только нажатие | 1: потерянный звонок дороже байтов |
| Тумблер режима | состояние с retain | оба фронта | 1: панель обязана увидеть смену |
| Удержание 3 секунды | событие hold после отсчёта | оба фронта плюс таймер | 1 |
| Телеметрия датчика | значения по millis | без фронтов | 0: пропажа одного замера переживается |
Retain к событиям не прикручиваю: retained-пакет «звонок» брокер отдаст каждому новому подписчику, и панель, открывшись утром, перезвонит вчерашний звонок. Retain — инструмент состояний: последний уровень, последний режим, последний статус связи. Если событие всё же должно переживать перезапуск панели — пусть это делает сервер: ставит у себя флаг и публикует собственное состояние уже с retain.
Различие «событие или состояние» я теперь проговариваю до первого скетча, потому что от ответа зависят три вещи сразу: retain (да — состоянию, нет — событию), фронты (оба — состоянию, один — событию) и цена потери (состояние живёт в следующем пакете, событие умирает молча). Схема топиков дома собирается из этих ярлыков за вечер; полную схему с ценой решётки я раскладывал в уроке про схему топиков. Новая кнопка в доме — две строки в таблице, а не спор с самим собой у макетки.
Вторая привычка — печатать фронт в Serial при отладке: строка «front press, ms=1234» экономит вечер, когда кнопка «двойнит». Причина чаще в длинном проводе кнопки без подтяжки, а не в скетче: щипки на длинной линии рисуют фронты сами. Лечение аппаратное — конденсатор 100 нФ параллельно кнопке, программное — уже знакомые 30-50 мс ожидания тишины.
- 1. Когда публиковать нажатие кнопки-звонка?
- 2. Сколько выждать антидребезг перед принятием состояния?
- 3. Нажали кнопку, а брокера нет. Что делает скетч из урока?
- 4. Чем плох QoS 1 в PubSubClient для loop?
- 5. Почему retain вреден для событий звонка?
- Кнопка — издатель событий: публикация на фронте после антидребезга 25-50 мс, не по циклу.
- Нажатие — для звонков и сбросов; тумблер публикует состояние с retain на обоих фронтах.
- Обрыв связи: буфер на одно событие — актуально последнее; для россыпи — кольцевой буфер.
- События — QoS 1 с пониманием, что publish блокирует loop на PUBACK; телеметрия — QoS 0.
- Retain для событий не ставим: панель перезвонит вчерашний звонок при каждом подключении.
Частые вопросы по теме урока
- Когда публиковать нажатие кнопки-звонка?
- Событие живёт на фронте: одно изменение — одна публикация. Цикл для кнопки даёт спам или потерю нажатия. На фронте изменения состояния после антидребезга
- Сколько выждать антидребезг перед принятием состояния?
- Контакты дребезжат 5-20 мс: окно 25-50 мс отделяет фронт нажатия от россыпи переходов. 25-50 мс
- Нажали кнопку, а брокера нет. Что делает скетч из урока?
- Ячейка pending: актуально последнее событие, LED горит, после reconnect буфер уезжает первым. Кладёт одно событие в буфер и отправит после reconnect
- Чем плох QoS 1 в PubSubClient для loop?
- Подтверждение ждётся внутри вызова: скетч замирает на время таймаута сокета, опрос пина встаёт. publish ждёт PUBACK синхронно и блокирует loop на таймаут
- Почему retain вреден для событий звонка?
- Retained-пакет брокер отдаёт каждому новому подписчику: утреннее открытие панели перезвонит прошлый звонок. Панель получит вчерашний звонок при подключении