Датчик на дальней мансарде держит связь через бетонную стену: Wi-Fi ловится, но с потерями, плата на батарейке, пробуждение раз в десять минут. Первая версия клиента отправляла красивый JSON с именем узла, названием датчика и единицей измерения — в логе смотрелось отлично. Когда я посчитал байты, красивых оказалось вчетверо больше полезных, и каждый из них проезжал злой радио-стык. Здесь считаем MQTT-пакет по частям, сравниваем сырой формат с JSON и находим границу, за которой документ окупается, а до которой — расточительство. Как строить сами документы для узлов умного дома, уже рассказано в уроке про JSON-payload — тут только цена. Оговорка для честности: на быстром домашнем Wi-Fi разницы вы не заметите вовсе — всё ниже про слабое радио, батарейку и плату без запаса кучи. Замеры мои, с узла на ESP8266: у вас цифры будут другими, метод останется тем же.
Из чего собирается пакет
Возьмём публикацию QoS 0 и разложим по слоям: два байта фиксированного заголовка (тип пакета и длина), два байта на длину топика, сам топик и payload. Всё. Для топика home/mansarda/temp — восемнадцать символов — и значения 18.5 — четыре байта — пакет выходит 26 байт: 2 + 2 + 18 + 4. Тот же смысл минимальным документом {"t":18.5} — десять байт payload, пакет 32. Полный самодокументированный вариант {"node":"mansarda","temp":18.5,"unit":"C"} — уже 42 байта payload и пакет под 64: слово mansarda едет и в топике, и в payload, то есть адрес платится дважды, а единицу измерения знает и так человек, который выбрал топик. Длина топика — тоже payload: брокер не дарит префиксы, каждый уровень иерархии едет в каждом сообщении. Короткие имена экономят честные байты, но не в ущерб смыслу — home/m/t экономит пятнадцать байт и стоит годовых догадок, что это было. Кстати, публикация с пустым payload весит шесть байт при коротком топике: некоторые так мигают присутствием, но пустая строка с retain удаляет значение у брокера — помните об этом, прежде чем слать заглушку.
Почему мелкий живее на слабом радио: TCP ретранслирует сегмент целиком, и каждая неудача на злой стене стоит пропорционально длине. Короткий пакет влезает в минимальный радио-кадр и реже режется на части при повторах. Вторая экономика — RAM: DynamicJsonDocument под один датчик занимает 96-192 байта кучи. На ESP32 это незаметно, на ESP8266 с его 80 КБ и пятью датчиками — уже политика: под каждый документ нужна память в момент сборки, а String-версии того же документа дробят кучу фрагментацией. Как устроены документы и что поменяла седьмая версия библиотеки, разобрано в уроке про JsonDocument v7. Про запас кучи условимся точно: у ESP32 сотни килобайт, и для неё всё это теория; у ESP8266 и у ATmega328 с двумя килобайтами RAM документ под ArduinoJson — предмет ежедневного бюджета.
Когда JSON окупается
Документ проигрывает в байтах и выигрывает в смысле, когда величин несколько. Три сырых публикации — три пакета по 26 байт и три пробуждения радио; один документ {"t":18.5,"h":45,"p":1008} — 26 байт payload и пакет под 48, одно пробуждение. На батарейке просыпаться дороже, чем передать лишний байт, так что связка «собрать три величины, отправить разом, уснуть» побеждает однозначно. Второй случай — умный дом: Home Assistant ждёт самодокументированные структуры, discovery-конфиги живут на JSON, и сырые значения туда приходится класть через обвязку, как в уроке про discovery. Третий — изменчивость: добавили вторую величину, документ примет её без переучивания подписчиков. И про числа: float в JSON — это символы, а не четыре двоичных байта; 18.5 едет четырьмя знаками, точность сверх нужной — лишние байты на каждый замер. Округляйте до десятых ещё на плате: график разницы не увидит, а эфир заметит.
Порог, который вывел для себя: одна-две однотипные величины — сырыми значениями, три и больше или разнородные — документом. И мерить, а не гадать: у ArduinoJson есть measureJson(doc) — размер документа в байтах без сборки строки, у String — length(). Скетч ниже печатает обе цены в Serial, и за день работы вы узнаёте реальный вес своих публикаций, а не интуицию о нём. Мой первый такой замер отрезвил: заголовки MQTT съедают 4 байта, топик — 18, и на их фоне разница payload 4 против 10 байт для брокера — ничто; зато каждая лишняя ретрансмиссия через бетон — пробуждение радио, за которое батарейка платит месяцами. Замер я устраиваю и до монтажа: узел стучит тестовые значения пару часов на столе, и по статистике длин видно, что документ с давлением гуляет от 24 до 27 байт из-за переменной длины чисел.
#include <WiFi.h>#include <PubSubClient.h>#include <ArduinoJson.h> const char* T_RAW = "home/mansarda/temp";const char* T_DOC = "home/mansarda/data"; WiFiClient net;PubSubClient client(net);DynamicJsonDocument doc(128);unsigned long lastPub = 0; void setup() { Serial.begin(115200); WiFi.begin("HomeNet", "12345678"); while (WiFi.status() != WL_CONNECTED) delay(300); client.setServer("192.168.1.50", 1883); client.connect("mansarda-01");} void loop() { client.loop(); unsigned long ms = millis(); if (ms - lastPub < 10000) return; lastPub = ms; float t = 18.5; // тут ваш датчик: dht.readTemperature() // сырой формат: только значение, топик уже сказал, что это температура char raw[12]; snprintf(raw, sizeof(raw), "%.1f", t); client.publish(T_RAW, raw); // документ: три величины одним сообщением doc["t"] = t; doc["h"] = 45; doc["p"] = 1008; String out = ""; out = serializeJson(doc, out); client.publish(T_DOC, out); Serial.print("raw: "); Serial.print(raw); Serial.print(" ("); Serial.print(strlen(raw)); Serial.print(" B) doc: "); Serial.print(out.length()); Serial.print(" B doc в RAM: "); Serial.println(measureJson(doc));}Обратите внимание на сырую ветку: без префиксов вида t=. Топик home/mansarda/temp уже сказал, что здесь температура, и префикс — второй платёж за адрес. Отдельные величины — отдельные топики — отдельные payload. А вот конструкции t=18.5;h=45 в одном топике — это самодельный микропротокол с ручным парсером: изобретаете плохой JSON, только без готовой библиотеки и без помощи Home Assistant. Потянуло на несколько величин в одном сообщении — берите документ, парсер у вас уже есть. И собирайте сырую ветку snprintf-ом в буфер на двенадцать байт, без String в горячем пути: куча не дробится, бюджет RAM предсказуем, а собранная строка всё равно живёт микросекунды. Формат, зафиксированный с двух сторон, я читаю и по индексам символов — но как только форматов больше одного, это уже парсер, и тогда сразу ArduinoJson.
Разбор команд: строкой или документом
Входящая сторона зеркальна. Payload «128» — это три символа, а не байт: MQTT не знает типов, PubSubClient отдаёт байты с длиной, и превращать их в число — ваша работа: те же грабли, что с Serial.parseInt в уроке про числа из Serial, и с молчаливым toInt из урока про payload-строки. Строковая команда дешева: сравнение с «on» — ноль парсинга и ноль лишней RAM. Документ дорог, но гибок: {"led":"on","bri":120} расширяется без смены протокола и живёт в Home Assistant как родной. Мой порядок в обработчике: короткая строка — быстрый путь, начинается с фигурной скобки — DynamicJsonDocument и deserializeJson. Команды света я держу строковыми, пока их меньше трёх, и перевожу в документ ровно в момент, когда приходят яркость и цвет. И одна грабля, которую я отлавливал неделю: payload в callback не завершается нулём — strcmp и atoi по сырым байтам читают чужую память после конца сообщения.
void onMessage(char* topic, byte* payload, unsigned int len) { if (len > 0 && payload[0] == 123) { // 123 - фигурная скобка DynamicJsonDocument doc(128); DeserializationError e = deserializeJson(doc, (char*)payload, len); if (e) return; // мусор - промолчали в лог if (doc["led"] == "on") setLed(true); if (doc["led"] == "off") setLed(false); return; } // строковые команды: payload не нультерминирован - копируем len байтов char buf[16]; if (len >= sizeof(buf)) len = sizeof(buf) - 1; memcpy(buf, payload, len); buf[len] = 0; if (strcmp(buf, "on") == 0) setLed(true); if (strcmp(buf, "off") == 0) setLed(false);}| Формат | Пакет на примере мансарды | Когда беру |
|---|---|---|
| Сырое значение: 18.5 | 26 байт: 2 заголовок + 2 длина + топик 18 + payload 4 | одна-две однотипные величины, батарейка, слабое радио |
| Минимальный JSON: {"t":18.5} | 32 байта: те же 22 плюс payload 10 | нужна расширяемость без смены топиков |
| Самодокументированный JSON | 64 байта: payload 42 с именем узла и единицей | Home Assistant, discovery, разнородные величины |
| Три величины одним документом | 48 байт против 78 за три сырых пакета | батареечный узел: одно пробуждение радио вместо трёх |
- 1. Топик home/mansarda/temp (18 символов), payload 18.5 (4 байта), QoS 0. Сколько весит пакет?
- 2. Почему сырые значения живее на слабом Wi-Fi?
- 3. Что дороже всего в JSON-публикации на слабой плате?
- 4. Батареечный узел шлёт три величины. Как публиковать?
- 5. Payload в callback не завершается нулём. Чем грозит strcmp по сырому указателю?
- Пакет QoS 0 = 2 байта заголовка + 2 байта длина топика + топик + payload
- Сырое значение 4 байта против полного JSON под 42: топик уже адрес — не платите за него дважды
- Несколько величин — один документ: 48 байт и одно пробуждение радио вместо трёх пакетов
- measureJson и length() печатают цену до деплоя — мерить, а не гадать
- Payload в callback без нуля на конце: копируйте len байтов перед strcmp
Частые вопросы по теме урока
- Топик home/mansarda/temp (18 символов), payload 18.5 (4 байта), QoS 0. Сколько весит пакет?
- Фиксированный заголовок 2 байта, два байта на длину топика, топик и payload: 2 + 2 + 18 + 4 = 26. Заголовков в счёте всегда два, даже когда payload пуст, — и транспорт сверху добавит свои шубы. 26 байт: 2 заголовка + 2 длина топика + топик + payload
- Почему сырые значения живее на слабом Wi-Fi?
- Ретрансмиссия транспорта тащит весь сегмент заново. Короткий пакет влезает в минимальный радио-кадр, и каждая потеря на злой стене стоит пропорционально длине — при пятой части потерь экономия удваивается. TCP ретранслирует сегмент целиком: чем короче, тем дешевле каждая неудача
- Что дороже всего в JSON-публикации на слабой плате?
- Документ живёт в куче в момент сборки: 96-192 байта на датчик. На ESP8266 с 80 КБ и пятью датчиками это уже политика, а String-сборки добавляют фрагментацию; serializeJson в статичный буфер стабильнее. RAM под DynamicJsonDocument и фрагментация кучи от String
- Батареечный узел шлёт три величины. Как публиковать?
- На батарейке просыпаться дороже, чем передать лишний байт: 48 байт одним документом против 78 тремя пакетами, и радио включается один раз. Три сырых публикации — это три нагрева радио и три согласования с точкой доступа. Один JSON: одно пробуждение радио вместо трёх
- Payload в callback не завершается нулём. Чем грозит strcmp по сырому указателю?
- PubSubClient отдаёт len байтов без терминатора. strcmp уползает за конец сообщения, читает чужую память, и команда срабатывает через раз. Скопируйте len байтов в буфер и допишите 0 — три строки кода, неделя отладки. Читает память за пределами сообщения и сравнивает с мусором