Датчик теплицы публикует температуру раз в минуту, и перед первым выкатом узла на постоянную жизнь у меня встал вопрос из документации: какой QoS поставить? Инстинкт был честный — раз есть три уровня надёжности, бери максимальный, данные дороже. Хорошо, что инстинкт я проверил чтением исходников и readme раньше, чем кодом в бою: ответ перевернул саму постановку вопроса и сэкономил мне вечер с осциллограммами.
В PubSubClient у publish нет аргумента QoS вовсе. Четвёртый аргумент одной из перегрузок — retained, а не qos: publish(topic, payload, retained) — и перепутать их до обидного легко, компилятор подскажет не всегда. Библиотека публикует только с нулевым уровнем, подписывается максимум с первым — subscribe(topic, qos) — и про второй уровень говорит прямо: не поддерживается, памяти в микроконтроллере мало. Мнение, к которому я пришёл: это не беда, а честный потолок, и дому его хватает с запасом.
Разберу три уровня по пакетам — что именно летит по проводу и чего это стоит, — затем решу, где какой нужен для температуры и команд, и покажу, где QoS в этой библиотеке живёт на самом деле: на стороне подписки, для входящих.
Три уровня по пакетам
QoS 0 — fire and forget: один пакет PUBLISH, и его судьба никого не интересует. Потерялся в эфире — потерялся; никто не узнает, потому что подтверждений не предусмотрено. QoS 1 — «хотя бы раз»: к пакету добавляется двухбайтовый идентификатор, получатель отвечает PUBACK, и до его прихода отправитель вправе ретранслировать. Отсюда честная цена первого уровня: если обрыв случился после получения, но до подтверждения, брокер отдаст сообщение подписчику второй раз — дубли возможны, и гасить их, если точность важна, придётся на приёме.
QoS 2 — «ровно раз»: четырёхшаговый обмен PUBLISH, PUBREC, PUBREL, PUBCOMP, где обе стороны ведут машину состояний и держат идентификаторы до завершения. Ровно потому, что этот уровень требует памяти под состояние, микро-клиенты его не берут: автор PubSubClient оговаривает это в readme, и форки, дописавшие второй уровень, платят тем же — буфером и кодом. Размер пакета растёт с уровнем: нулевой едет без идентификатора, первый и второй тащат его в каждом пакете обмена.
Стоимость уровней видна в модели ниже: на чистой сети разница между нулём и единицей — один лишний пакет на сообщение; на паршивой — ретрансмиссии и очередь. При телеметрии раз в минуту разница незаметна; при пачке догоняющих точек из урока про оффлайн-буфер — уже да: сотни подтверждений растягивают раздачу в два-три раза, я писал об этом в уроке про раздачу копилки.
Накладной расход посчитал на своём показании: {"t":21} с темой home/teplica/temp — двадцать восемь байт нулевым уровнем, тридцать первым — на два байта больше вверх и четыре байта PUBACK вниз. Раз в минуту это шесть байтов и один лишний обмен на точку; за сутки — восемь с половиной килобайт и тысяча четыреста сорок обменов. На домашнем брокере незаметно, а на LTE-стыке с RTT двести миллисекунд те же обмены — почти пять минут эфира в сутки, и вот там выбор уровня перестаёт быть теорией. Ретрансмиссии — вторая статья расхода: mosquitto ветки 1.x повторяет неподтверждённое по таймеру retry_interval, по умолчанию двадцать секунд; с версии 2.0 настройку убрали — повторять стал сам TCP. Повтор уходит с битом DUP и тем же идентификатором пакета: по этой паре приёмник отличает дубль от нового сообщения.
Где QoS живёт в PubSubClient
Исходящий уровень у библиотеки один — нулевой, и спорить с этим бесполезно; зато входящий выбирается: subscribe(topic, 1) просит брокера доставлять команды с подтверждением. Пока клиент в сети, брокер досылает неподтверждённое сам; при чистой сессии очередь живёт, только пока сессия цела, при clean session=false — и после переподключения. Для команд «включи свет» это осмысленно: панель отправила — плата, отшумевшись, получит. Для исходящей телеметрии выбор ничего не даёт: publish всегда едет с нулём.
Если исходящий QoS 1 нужен осознанно, есть форк PubSubClient3 — тот дописывает публикацию с подтверждением; я пробовал менять библиотеку ради этого и вернулся обратно: для дома подтверждение на приёме — ответом в state-топик — даёт ту же уверенность дешевле и без смены зависимостей. Как строится пара «команда — ответ в state», показано в уроке про реле с подтверждением и в следующем уроке кластера, про свет из Home Assistant. Входящие команды и их маршрутизация в callback разобраны в уроке про subscribe и callback.
Подтверждение входящего — незаметная обязанность loop: PUBACK на принятую команду библиотека шлёт внутри client.loop(), сразу после выхода из callback. Пока обработчик крутится, подтверждение не уходит, и брокер по своему таймеру вправе счесть доставку незавершённой. Отсюда моё правило зазоров: между проходами loop — не дольше пяти секунд при keepalive пятнадцать: запас втрое, и PINGREQ, и PUBACK уезжают без очереди. Потолки у брокера тоже конечны: mosquitto держит в полёте не больше двадцати неподтверждённых сообщений на клиента (max_inflight_messages), а очередь для оффлайнового клиента с clean session=false копит сотню в ветке 1.x и тысячу с версии 2.0 (max_queued_messages). Тихий клиент, который не подтверждает входящее, упирается сначала в потолок полёта, потом в потолок очереди — и новые команды перестают доходить вовсе, хотя соединение формально живо.
Дубли первого уровня гашу на приёме, оба способа стоят копейки. Команда бывает идемпотентна сама: повторный ON на уже включённом реле ничего не меняет — состояние съедает дубль без кода. Телеметрии, где каждая точка должна лечь в график один раз, я вешаю счётчик в payload: {"t":21,"seq":1042} — приёмник помнит последний seq, повтор роняет молча; цена — четыре-шесть байт и одна переменная. Честный минус: счётчик живёт в памяти приёмника и обнуляется с его ребутом — первые сообщения после рестарта панели пройдут все. Для домашних графиков стерпел; для счётчика денег не стерпел бы — но деньги в доме не через PubSubClient едут, о том и строка в таблице ниже.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); void mqttCallback(char* topic, byte* payload, unsigned int len) { // команды приходят с QoS 1: брокер повторит, пока не увидит подтверждение if (strcmp(topic, "home/teplica/cmd") == 0) { // payload разбираем быстро: длинная работа в callback рвёт соединение }} void connectMqtt() { client.setServer(MQTT_SERVER, 1883); if (client.connect("teplica-qos")) { client.subscribe("home/teplica/cmd", 1); // входящие: хотя бы раз client.publish("home/teplica/availability", "online", true); }} void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); connectMqtt();} void loop() { client.loop(); if (!client.connected()) { reconnectByTimer(); } static unsigned long lastPub = 0; if (client.connected() && millis() - lastPub >= 60000) { lastPub = millis(); // у publish нет аргумента QoS: телеметрия едет с нулевым уровнем, // четвёртый аргумент перегрузки — retained, а не qos char msg[24]; snprintf(msg, sizeof(msg), "{\"t\":%d}", analogRead(A0) / 20 - 5); client.publish("home/teplica/temp", msg); }}Что выбрать для датчика
Для телеметрии хватает нуля, и вот арифметика. Температура раз в минуту — точка не устаревает и не копится в долг; потерянная точка превращается в дыру шириной в минуту, следующая её закрывает. PUBACK на каждую точку удвоил бы пакеты, а на паршивой связи добавил ретрансмиссии — оплата за видимость надёжности. Моё правило: то, что измеряется снова и снова, едет с нулём. Дисклеймер для спора: если у вас редкие события — одна точка в час, — потеря уже не дыра, а пропавший час, и тогда форк с QoS 1 на публикацию оправдан.
Команды — противоположность: событие единственное, повторить его отправитель не может, а потерянное «включи насос» стоит затопленного сарая. На подписку ставлю первый уровень, а поверх — прикладное подтверждение ответом в state-топик: панель видит не «брокер доставил», а «плата применила». Дубль команды при обрыве в момент ответа гасится самим состоянием: реле уже включено, повторный ON безвреден. Шаблон идемпотентной команды снял с меня половину страхов о дублировании на первом уровне.
Уровень подписки и сессии связаны: команды, ушедшие при мёртвой плате, сохранит только clean session=false — но тогда брокер держит очередь и состояние для каждого клиента. Домашний mosquitto переваривает десятки узлов без заметных затрат, я проверял на двадцати; на публичных брокерах за сессии считают память и деньги — там первая цена за persistent-сессию заставляет пересмотреть весь зоопарк подписок.
Шторм после reconnect добавляет единице свою цену, и она прячется не в уровне, а в темпе. Брокер встал и вернулся — два десятка узлов рвутся разом: наивный цикл с паузой сто миллисекунд между попытками штампует десять CONNECT в секунду с каждого узла, двести со двора, а clean session=false сверху вываливает накопленные очереди QoS 1 — на одноплатном брокере эти секунды видны по загрузке процессора, а на панели статусы сыплются пачкой. Оговорка для спора: пять узлов на локальном брокере ничего не заметят и городить нечего; два десятка — уже повод. Лечится темпом, а не уровнями: пауза между попытками от пяти секунд с ростом и джиттером, чтобы узлы не ломились в ногу, — разбор в уроке про reconnect с backoff.
| Сценарий | Мой уровень | Почему |
|---|---|---|
| Температура и влажность раз в минуту | QoS 0 на publish | следующая точка закроет потерю; подтверждения лишь съедают эфир |
| Команда свету и реле, входящая | subscribe с QoS 1 | событие единственное, брокер досылает неподтверждённое |
| Счётчик воды и энергии | QoS 0 и одно растущее число | аккумулируется на плате; механика в уроке про счётчик импульсов |
| Ровно один раз для денег и билетов | не в этой библиотеке | QoS 2 не поддержан; дедупликация по идентификатору — на приёме |
- 1. Какой QoS ставит PubSubClient исходящему publish?
- 2. Пакет QoS 1 потерялся в эфире. Что дальше?
- 3. Обрыв случился после того, как подписчик получил QoS1-сообщение, но до PUBACK. Что увидит подписчик?
- 4. Где в PubSubClient я выбираю QoS 1 осознанно?
- 5. Почему QoS 2 не подходит ESP8266-узлу на PubSubClient?
- У publish в PubSubClient нет аргумента QoS: телеметрия всегда нулевой уровень; четвёртый аргумент перегрузки — retained.
- QoS 0 — один пакет без подтверждений; QoS 1 — packet id и PUBACK, дубли возможны; QoS 2 — четыре шага и память под состояния.
- Мой выбор: телеметрия — ноль, команды — единица на подписке и подтверждение ответом в state-топик.
- Буфер по умолчанию 256 байт: QoS 2 с его состояниями на ESP8266 не влез бы и без того; ровно-один-раз ищите в другой библиотеке.
- На паршивой связи QoS 1 растит время доставки ретрансмиссиями: на пачках точек это видно сильнее всего.
Частые вопросы по теме урока
- Какой QoS ставит PubSubClient исходящему publish?
- Библиотека публикует только с QoS 0; четвёртый аргумент перегрузки publish — retained, а не qos. Подписка выбирается до первого уровня. Только нулевой: аргумента QoS у publish нет
- Пакет QoS 1 потерялся в эфире. Что дальше?
- Первый уровень — «хотя бы раз»: до PUBACK отправитель вправе повторять посылку. Нулевой уровень тихо теряет, второй — четырёхшаговый обмен. Отправитель ретранслирует, пока не придёт PUBACK
- Обрыв случился после того, как подписчик получил QoS1-сообщение, но до PUBACK. Что увидит подписчик?
- Брокер не дождался подтверждения и повторит доставку — отсюда дубли на «хотя бы раз». Гасятся они состоянием или идентификатором на приёме. Вторую копию: на первом уровне дубли возможны
- Где в PubSubClient я выбираю QoS 1 осознанно?
- Исходящий publish всегда нулевой; единица живёт на подписке — брокер досылает неподтверждённые входящие, пока сессия цела. В subscribe(topic, 1) для входящих команд
- Почему QoS 2 не подходит ESP8266-узлу на PubSubClient?
- Автор библиотеки оговаривает QoS 2 из-за памяти: 256 байт буфера по умолчанию не оставляют запаса под состояния обмена. Ровно-один-раз — повод сменить библиотеку. Не поддержан библиотекой: четыре шага съели бы и без того скромный буфер