Дачный узел в теплице публиковал три топика: dacha/greenhouse/temp, dacha/greenhouse/hum и dacha/greenhouse/soil. Дашборд рисовал три карточки, и всё выглядело прилично — пока я не начал сверять значения между собой. Полдень, панель утверждает: 23.5 градуса в 12:00:01 и влажность 48 процентов в 12:00:07. Шесть секунд разницы, а выглядит как показания из разных суток: я два вечера подозревал датчик во вранье, хотя врал порядок доставки — три отдельных пакета доезжали в разное время, и панель честно показывала то, что успела получить.
Вторая заноза была организационной. Добавил датчик CO2 — появился четвёртый топик, ещё одна подписка на дашборде, ещё одна строка в mosquitto_sub для отладки. Отладочная подписка на dacha/greenhouse/# сыпала тремя строками на каждый цикл опроса, и после недели работы листать её хотелось разве что с наушниками. Через полгода таких добавлений список топиков разросся до десятка, половину завёл уже не я, и моим запискам в блокноте я сам не доверял: в каком из них какая единица ездит — уже вопрос веры.
Лечение — перевод телеметрии на структурированный payload: одно устройство, один топик, один JSON-документ со всеми показателями. Дашборд разбирает документ один раз и раскладывает по карточкам, mosquitto_sub печатает одну строку на цикл вместо трёх, а новая величина превращается из проекта на вечер в одно присвоение. В этом уроке собираем документ через ArduinoJson, публикуем его по millis-таймеру, разбираем на стороне подписчика и честно считаем, когда один документ проигрывает трём топикам — потому что проигрывает не всегда, но и выигрывает не везде.
Один документ вместо трёх листков
Payload в MQTT — байты без формата, протоколу всё равно, что внутри, мы это разбирали ещё в уроке про устройство протокола. Структуру туда приносит ArduinoJson: заводите документ, кладёте значения по именам, сериализуете — и в топик уходит {"t":23.5,"h":48} вместо трёх отдельных публикаций. Топик один — dacha/greenhouse/state, а показателей внутри сколько угодно: новый показатель не добавляет ни подписок, ни таймеров в скетче.
Порядок работы: DynamicJsonDocument doc(256) — ёмкость под будущий текст; doc["t"] = t; doc["h"] = h; — присвоения по именам; serializeJson(doc, out) — сборка строки. Никакой ручной склейки: запятые между полями и кавычки вокруг ключей библиотека ставит сама — те самые запятые, из-за которых я когда-то три вечера правил скетч руками; этот тупик и выход из него разобраны в уроке про сериализацию своего JSON.
Число с условием: документ из двух коротких полей занимает 17–20 байтов, из трёх с именем устройства — 45–60; к ним топик в 22 символа и пара байтов заголовка пакета. Выигрыш не в байтах — они копеечные, — а в атомарности: вся телеметрия доезжает одним куском, и рассинхрон между полями исчезает как класс.
#include <WiFi.h>#include <PubSubClient.h>#include <ArduinoJson.h>#include <DHT.h> WiFiClient net;PubSubClient client(net);DHT dht(4, DHT22);DynamicJsonDocument doc(256); // ёмкость под будущий текст unsigned long lastPublish = 0;const unsigned long PERIOD = 10000; // раз в 10 секунд void loop() { if (!client.connected()) { reconnect(); // из прошлого урока } client.loop(); unsigned long now = millis(); if (now - lastPublish >= PERIOD) { lastPublish = now; float t = dht.readTemperature(); float h = dht.readHumidity(); if (isnan(t) || isnan(h)) { return; // датчик молчит - снимка не будет } doc["t"] = t; doc["h"] = h; String out = ""; serializeJson(doc, out); // {"t":23.5,"h":48} client.publish("home/balcony/state", out.c_str()); Serial.print("home/balcony/state "); Serial.println(out); }}Разбор по строкам, потому что каждая когда-то была багом. Документ живёт глобально, а не внутри if: ArduinoJson переиспользует память между публикациями, и на ESP32 это незаметно; на Uno глобальные 256 байтов — восьмая часть SRAM, и лучше видеть эту строку в бюджете заранее, чем находить её, когда куча уже крошится. Присвоения стоят до serializeJson: сериализация фиксирует документ в момент вызова, а не «в конце loop». Ёмкость 256 я беру под два-три коротких поля с запасом вдвое; как только документ намечается расти, печатаю measureJson(doc) — точная длина вместо гаданий, и заодно видно, во что обходится каждое новое поле.
На стороне подписчика всё зеркально. Payload, собранный в String по length, уходит в deserializeJson(doc, msg), дальше значения читаются по именам: doc["t"], doc["h"]. Сам приём callback'ом мы строили в уроке про subscribe и callback, здесь к нему добавляются четыре строки разбора; коды провала разбора и их диагностика — в уроке про deserializeJson, без проверки кода документ после неудачи молчит нулями.
Графикам такой формат не родной: серия ждёт одну величину. Поэтому мой дашборд после приёма раскладывает документ по трём сериям — раз написанный разбор живёт годами. В Home Assistant этот шаг делают за вас: payload из топика состояния разбирается шаблоном value_template, и каждая величина уезжает в свою сущность.
Вложенность и массивы: цена байта
ArduinoJson умеет и глубже: createNestedObject заводит карман, createNestedArray — массив. Отчёт раз в час вида {"dev":"teplica1","h":[21.4,21.6,21.5]} с двадцатью четырьмя значениями — законный случай: один пакет вместо двадцати четырёх, подписчику удобнее принять разом, а вся история помещается в один retained-статус. Как устроены вложенные структуры при разборе — отдельная тема урока про массивы и вложенность; при сериализации механика та же, только в обратную сторону.
Для регулярной телеметрии моё правило — плоский документ: верхний уровень, короткие ключи t, h, p. Каждый уровень вложенности — это кавычки, скобки и байты: та же пара значений в кармане sensors обрастает восемью-десятью байтами, а на Uno каждый байт документа берётся из тех же двух килобайтов, что и буфер сети. Вложенность уместна в отчётах и конфигах, где её цена окупается читаемостью; если документ начинает упираться в буфер — это ровно тема следующего урока.
| Критерий | Три топика | Один JSON-документ |
|---|---|---|
| Пакетов на цикл | три публикации, три пакета | один пакет, вся телеметрия атомарно |
| График в дашборде | строится сразу: топик равен серии | нужен разбор документа на серии |
| RAM и буфер | по 55–60 байтов на пакет, документ не нужен | плюс ArduinoJson: от 256 байтов под документ |
| Новый показатель | новый топик, подписка, строка в скетче | одно присвоение doc перед serializeJson |
| Retained-статусы | по каждому полю отдельно | статус только у всего снимка целиком |
| Отладка в терминале | три строки на цикл опроса | одна строка: весь снимок в одном payload |
Таблица ведёт к честному выводу: если показатели едут к разным потребителям с разными ритмами, три топика остаются лучше. Насос я так и публикую — 1 или 0 в отдельный топик: команду реле не хочется резать из документа, а история команд в логе брокера читается без разбора JSON. Это то же правило «одна величина — один топик» из урока про публикацию показаний, только теперь с исключением, когда оно не работает.
JSON выигрывает, когда показатели описывают одно состояние и едут к одному читателю: снимок теплицы, телеметрия с уровнем батареи и качеством сигнала, отчёт раз в час. Тепличный узел после переезда на один документ тратит треть прежнего числа пакетов, а карточки перестали врать друг другу про время. Отдельный бонус — retained: одно сообщение хранит весь снимок, и новый подписчик после подключения видит полный статус устройства сразу, а не тот его кусок, который успел опубликоваться последним.
В симуляторе ниже и сборка, и разбор документа настоящие: ArduinoJson в движке живой. Публикация уходит в виртуального брокера, а входящее сообщение для разбора вводится в монитор порта — callback в движке не подключается, поэтому роль брокера играет строка ввода, как в отладке через Serial. Числа в мониторе идут парами имя=значение — привычка из уроков про порт: t=21 и n=4 ложатся в Serial Plotter ровными рядами, а сам JSON в строке MQTT читается глазами без препарирования.
- Добавьте в документ третий показатель: doc["p"] с давлением из random(730, 751). Запустите и посчитайте в мониторе, на сколько вырос размер пакета, — сверьте с арифметикой из урока.
- Пошлите в монитор {"t":30,"h":40}, затем {"t":18,"h":70}: реле щёлкнет по порогу из скетча. Сравните с белым списком из callback-урока: там проверялось одно значение, здесь — поле документа.
- Пошлите {"t":26} без поля h: разбор пройдёт, а чтение doc.h вернёт ноль. Добавьте печать doc.containsKey("h") и убедитесь, что необязательное поле проверяется явно.
- Поменяйте период публикации с 4000 на 8000: поток реже, атомарность не страдает. Подумайте, какой период честно нужен вашей телеметрии.
- Один топик с документом против трёх листков: телеметрия доезжает атомарно, рассинхрон между полями исчезает
- Сборка: DynamicJsonDocument, присвоения по именам, serializeJson в строку; запятые и кавычки ставит библиотека
- Приём: payload в String по length, deserializeJson с проверкой кода, чтение doc по именам
- Телеметрию держу плоской с короткими ключами; вложенность и массивы — для отчётов, где байты окупаются читаемостью
- Формат меняется в одну сторону: поле добавить можно, переименовать — только через новый топик и параллельную публикацию
- 1. Сколько пакетов уходит в брокер при телеметрии из трёх показателей, собранной в один JSON-документ?
- 2. В документ добавили новое поле. Что увидит старый подписчик?
- 3. Почему для регулярной телеметрии я держу документ плоским?
- 4. Как подготовить payload из callback перед deserializeJson?
- 5. Что лучше для команды насосу со значениями 1 и 0?
Частые вопросы по теме урока
- Сколько пакетов уходит в брокер при телеметрии из трёх показателей, собранной в один JSON-документ?
- Сериализованный документ становится payload одной публикации: один пакет с топиком и телом. Три топика давали бы три пакета и три момента доставки, между которыми подписчик ловит разные секунды. Один: документ публикуется целиком
- В документ добавили новое поле. Что увидит старый подписчик?
- Подписчик читает документ по именам ключей: появление нового поля его не касается. Опасно не добавление, а переименование или удаление старого. Ничего: он читает свои ключи как раньше
- Почему для регулярной телеметрии я держу документ плоским?
- Кавычки, скобки и карманы — это байты в payload и память под документ. На Uno те же два килобайта SRAM делят документ, буфер сети и стек — каждый уровень вложенности снимает с этого бюджета своё. Каждый уровень вложенности добавляет байты и ест память документа
- Как подготовить payload из callback перед deserializeJson?
- Payload приходит байтами без нуля на конце: собираем строку циклом по length, как в callback-уроке, и отдаём deserializeJson. Мусор за length — не ваши данные, а чужая память. Собрать в String по length и передать строку в разбор
- Что лучше для команды насосу со значениями 1 и 0?
- Команда — событие для одного потребителя: отдельный топик читается в логе брокера без разбора JSON и не зависит от ритма телеметрии. Поле в документе я бы тоже прожевал, но история команд в отдельном топике читается с первого взгляда даже через месяц. Отдельный топик со значением 1 или 0