В апреле я посадил рассаду под насос в сарае: панель в доме раз в шесть часов отправляет «полей три минуты». Утром грядки были сухие, а журнал панели показывал честное «отправлено». Команды уходили, брокер принимал, а насос не качал: сарайская плата как раз в эти минуты висела на переподключении Wi-Fi, пакет утекал мимо, и никто об этом не узнавал. QoS 1 у публикации стоял — и именно поэтому случай стал для меня самым полезным уроком года.
Разбираться пришлось с корня: что именно обещает QoS 1. Обещает он ровно одно — пакет дойдёт до брокера, PUBACK вернётся, при потере пакет уйдёт снова. Всё. «Отправлено» в моём журнале означало «брокер принял», а не «насос качает», и между этими фразами помещалась вся рассада.
Лечение построено поверх QoS 1, а не вместо него: у каждой команды номер, у исполнителя — свой ack-топик, у отправителя — таймаут 3 секунды и максимум три попытки. Не подтвердилось — команда уходит снова с тем же номером; исполнившая сторона дубль игнорирует, но подтверждает ещё раз. Ниже — механика целиком, с журналом и симулятором, в котором ack подаёт кнопка.
Что обещает QoS 1, а что — нет
Три уровня доставки различаются рукопожатием. QoS 0 — один пакет без квитанции: потеряли — забыли. QoS 1 — два пакета, PUBLISH и PUBACK: «минимум один раз», поэтому на дребезге сети возможны дубликаты. QoS 2 — четыре: PUBLISH, PUBREC, PUBREL, PUBCOMP — «ровно один раз», но каждое сообщение обрастает перепиской, и брокер держит состояние транзакции. Теорию уровней с mosquitto_sub в руках я разбирал в уроке про уровни доставки — здесь от неё нужна одна грань.
Грань такая: PUBACK — это подпись брокера, а не устройства. В моей схеме издатель — панель в доме, подписчик — плата в сарае, и QoS 1 тянет два отрезка: панель-брокер и брокер-плата. Но между «плата получила байты» и «плата щёлкнула реле» лежит скетч: перезагрузка посреди callback, зависший пин, мёртвый ключ транзистора — протокол этого не видит и не обязан. Разбор входящих с флагами и без самодеятельности — из урока про подписку и callback; гарантия же исполнения достигается только самим исполнителем, словами протокола её не выразить. Самый дорогой вариант — QoS 2 — за все сезоны ни разу не понадобился: реле не бухгалтерия, дубликат с номером безвреден, а четырёх пакетов на команду жалко.
В PubSubClient уровень включается четвёртым аргументом публикации: client.publish("cmd/rele", payload, false, 1) — false это retain, единица это QoS. Обратная сторона тоже стоит слов: подписка у библиотеки всегда идёт с QoS 0, параметра там нет вовсе, — то есть отрезок «брокер-плата» по умолчанию едет по нижней шкале, и ещё одна причина строить ack поверх. И пока я жду подтверждение и жму повтор, loop обязан крутиться дальше: висеть на таймауте с блокирующим delay значит рвать keepalive — цену тишины в канале я считал в уроке про keepalive.
| QoS | Рукопожатие | Гарантия | Слабое место |
|---|---|---|---|
| 0 | PUBLISH, и всё | максимум один раз; потерялся — никто не узнает | команда утекает молча: мой случай с рассадой |
| 1 | PUBLISH → PUBACK | минимум один раз, дубликаты возможны | доставка есть, исполнения нет: нужен ack |
| 2 | PUBLISH → PUBREC → PUBREL → PUBCOMP | ровно один раз | четыре пакета и состояние транзакции на брокере |
| QoS 1 + ack-топик | мой слой поверх библиотеки | исполнение подтверждено устройством с номером | терпеть 3 с таймаут и вести журнал |
Паттерн разворачивается в четыре строки договора. Первая: каждая команда несёт номер — payload вида 1#41, где 1 — значение, 41 — идентификатор; номер растёт на единицу и не повторяется. Вторая: исполнитель, щёлкнув реле, публикует в ack/rele отчёт с тем же номером. Третья: отправитель держит таймаут 3 секунды — не дождался ack, шлёт команду снова. Четвёртая: попыток максимум три, дальше отбой и запись в журнал.
Номер — не бюрократия, а единственная защита от дублей. QoS 1 врёт в сторону избытка: сеть дрогнула на PUBACK — брокер сочтёт пакет недоставленным и повторит, и исполнитель увидит одну команду дважды. Если команда звучит «переключи свет», дубль превращает её в лотерею — этот случай разобран в уроке про уровни доставки, там же правило «toggle и QoS 1 — враги». С номером дубль безвреден: 41 уже исполнена, реле второй раз не щёлкнет, а ack уйдёт снова — вдруг первый как раз потерялся.
Числа выбраны не с потолка. Таймаут 3 секунды: меньше — наслушаешься ложных повторов на загруженном Wi-Fi, больше — панель слишком долго живёт с непонятным состоянием; старт моего насоса занимает секунды, трёх хватает с запасом. Максимум 3 попытки: если исполнитель мёртв целиком, четвёртый и пятый пакеты его не оживят, а лог превратится в спам; три — рубеж, после которого честнее сказать «не отвечает» и уйти в тревогу, чем продолжать стрелять. Оба числа держи константами в шапке скетча: за три сезона я менял таймаут один раз — на дачной сети он вырос до пяти секунд, — а потолок попыток так и остался тройкой.
#include <PubSubClient.h> WiFiClient net;PubSubClient client(net);const char* T_CMD = "cmd/rele";const char* T_ACK = "ack/rele";const int LED = 12; // мигает, пока команда не подтвержденаconst unsigned long TIMEOUT = 3000; // мс ожидания ackconst int MAX_POPYTOK = 3; int id = 40; // растёт с каждой командой и не повторяетсяint zhdemId = 0; // чей ack ждём (0 — не ждём ничего)int zhdemZnach = 0;int popytki = 0;unsigned long poslana = 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_ACK || p.toInt() != zhdemId) { return; } zhdemId = 0; // подтверждено: не ждём и не повторяем digitalWrite(LED, LOW); Serial.print("ack: #"); Serial.print(p.toInt()); Serial.print(" za "); Serial.print(millis() - poslana); Serial.println(" ms");} void sendCmd(int znach) { id++; zhdemId = id; zhdemZnach = znach; popytki = 1; poslana = millis(); String pl = String(znach) + "#" + String(id); client.publish(T_CMD, pl, false, 1); // QoS 1: брокер отвечает за доставку Serial.print("otpravil: "); Serial.print(pl); Serial.println(" (popytka 1)");} void povtor() { popytki++; poslana = millis(); String pl = String(zhdemZnach) + "#" + String(zhdemId); client.publish(T_CMD, pl, false, 1); Serial.print("povtor: "); Serial.print(pl); Serial.print(" (popytka "); Serial.print(popytki); Serial.println(")");} 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-panel")) { delay(2000); } client.setCallback(callback); client.subscribe(T_ACK);} void loop() { client.loop(); if (Serial.available() > 0) { String msg = Serial.readStringUntil(10); msg.trim(); if (msg == "send 1" && zhdemId == 0) { sendCmd(1); } else if (msg == "send 0" && zhdemId == 0) { sendCmd(0); } else { Serial.println("zhdu podtverzhdeniya tekuschej komandy"); } } if (zhdemId != 0) { digitalWrite(LED, (millis() / 250) % 2 == 0 ? HIGH : LOW); if (millis() - poslana >= TIMEOUT) { if (popytki < MAX_POPYTOK) { povtor(); } else { Serial.print("#"); Serial.print(zhdemId); Serial.println(": 3 popytki bez ack, ispolnitel molchit"); zhdemId = 0; digitalWrite(LED, LOW); } } }}Журнал здесь не украшение, а третья рука: глядя в Serial, я за минуту отвечаю, на каком звене всё встало. «otpravil» без «ack» — сеть или мёртвый исполнитель, дорога ведёт в сарай. «ack» без «otpravil» — кто-то шлёт подтверждения без команд: чужой издатель на топике. Пары «otpravil/ack» с номерами и временем между ними — минимальный журнал, который окупается с первой же второй командой. Время между отправкой и квитанцией — ещё и телеметрия: эти миллисекунды печатаются сами, и скачок со 300 на 2500 мс за неделю однажды сказал мне о деградации Wi-Fi раньше, чем связь пропала совсем.
Симулятор ниже ставит тебя на место отправителя: команда с номером уходит с QoS 1 и попадает в журнал, led на D12 мигает, пока ack не пришёл. Кнопка на макетке изображает исполнителя — на реальной сети его ack прилетел бы в callback из брокера, в симуляторе брокерских входящих нет, поэтому подтверждение подаёшь ты нажатием кнопки или строкой ack в мониторе. Не трогай ничего три секунды — увидишь povtor с тем же номером, а после третьей попытки — честный отбой.
Последняя деталь, на которой я обжигался: ack должен уходить после фактического щелчка реле, а не до. Исполнитель, публикующий подтверждение до digitalWrite, продаёт воздух — панель погасит led, журнал скажет «исполнено», а насос будет стоять на мёртвом ключе, о котором я писал в уроке про модуль реле. Порядок в исполнительном скетче строгий: щёлкнуть, затем отчитаться; между ними на моей плате даже нет Serial.print — журнал не должен вставать между действием и квитанцией.
Симулятор: подтверждение кнопкой
Симулятор собирает обе роли в одной макетке, и каждая деталь имеет должность: реле на D7 — нагрузка, которая получает питание только по факту подтверждения; led на D12 — пульс ожидания, гаснущий при ack и при отбое; кнопка — исполнитель со своей рукой. Флаг btnByl ловит именно фронт нажатия, поэтому одно нажатие равно одному подтверждению, без дребезга повторов.
Что вынести с макетки в сарай: смена состояния нагрузки в момент ack, а не в момент отправки — панель всегда рисует подтверждённую правду, и рассада от этого выигрывает напрямую. Если захочется продолжения — добавь в payload ещё и текущее состояние насоса, а в ack — значение после щелчка: тогда панель сможет сверять, что исполнено ровно то, что просили. Это уже два шага до полноценного конвейера состояния, описанного в уроке про свет из Home Assistant.
- PUBACK — подпись брокера: QoS 1 гарантирует доставку пакета, но не щелчок реле
- QoS 0 — один пакет без квитанции, QoS 1 — PUBLISH и PUBACK с возможными дублями, QoS 2 — четыре пакета и ровно один раз
- Договор ack: payload вида 1#41, номер не повторяется, исполнитель отчитывается в ack/rele после щелчка
- Таймаут 3 с, максимум 3 попытки, дальше отбой: мёртвому исполнителю пятый пакет не поможет, а лог заспамится
- В PubSubClient QoS задаётся четвёртым аргументом publish, у subscribe его нет вовсе — ack и прикрывает слабый отрезок
- 1. Что именно подтверждает PUBACK при публикации с QoS 1?
- 2. Панель шлёт «переключи свет» с QoS 1, сеть дрогнула на PUBACK — брокер повторит пакет. Чем опасен дубль?
- 3. Сколько пакетов обмениваются издатель и брокер при QoS 2?
- 4. Почему у повторов стоит потолок в три попытки, а не бесконечность?
- 5. Когда исполнитель обязан публиковать ack?
Частые вопросы по теме урока
- Что именно подтверждает PUBACK при публикации с QoS 1?
- PUBACK — квитанция брокера. Исполнение лежит за пределами протокола: брокер не знает, есть ли у подписчика руки. брокер принял пакет на доставку
- Панель шлёт «переключи свет» с QoS 1, сеть дрогнула на PUBACK — брокер повторит пакет. Чем опасен дубль?
- QoS 1 обещает «минимум один раз» — дубликаты законны. Toggle, исполненный дважды, возвращает всё на место; лечение — номер команды и игнор уже исполненных. команда-переключатель сработает дважды, и свет вернётся как был
- Сколько пакетов обмениваются издатель и брокер при QoS 2?
- Четыре пакета и хранимое состояние транзакции — цена «ровно один раз». У QoS 0 один пакет, у QoS 1 два. четыре: PUBLISH, PUBREC, PUBREL, PUBCOMP
- Почему у повторов стоит потолок в три попытки, а не бесконечность?
- После трёх честнее уйти в тревогу «исполнитель молчит». Протокол потолков не ставит — это дисциплина отправителя, а не требование стандарта. мёртвый исполнитель от пакета не оживёт, а лог и сеть превратятся в спам
- Когда исполнитель обязан публиковать ack?
- Подтверждение до действия продаёт воздух: журнал скажет «исполнено» на стоящем насосе. Порядок строгий: действие, потом квитанция. после фактического щелчка реле