Тепличный узел на батарейке месяц просидел без протокола: датчик честно отдавал температуру и влажность, сервер просил одну строку раз в минуту, а я всё усложнял — группы, версии, подписи полей. Урезал до плоской телеграммы из пяти полей — {"device":"dacha-1","ts":184,"t":23.4,"h":56,"vcc":3872} — и узел ожил: пятьдесят шесть байт на показание, минута за минутой, ни одна строка не собирается конкатенацией, куча не потеет. Все протокольные споры с сервером сошлись в одну строку: телеграмма — это и документ, и буфер, и план отправки одновременно.
Датчик подключали в уроке про DHT11; пакеты с историей одного датчика собирали в уроке про пакет для сервера, группы из трёх датчиков — в уроке про телеметрию. Здесь третий срез: узел, который шлёт один и тот же состав по таймеру, — где цена каждого поля пересчитана в байты, печать идёт и в char-буфер, и в Serial, а отправка живёт по millis-планировщику из урока про millis вместо delay. Отдельно про vcc: поле, о котором в уроках про розеточное питание не думают, — на батарейном узле оно окупает весь скетч, потому что рассказывает о смерти узла заранее.
План: анатомия полей и их цена в байтах, сериализация в буфер с предохранителем, планировщик без блокировки и отладочная pretty-печать по кнопке. Числа в этом уроке сняты со строки, которую вы сами соберёте в симуляторе ниже, — каждый байт из таблицы проверяем на живой печати.
Анатомия телеграммы
device — строка-имя узла: станция различает десятки отправителей, и длина имени платит байтами при каждом показании, поэтому короткое и без пробелов: dacha-1, а не «Дачный узел номер один». ts — секунды с включения: millis()/1000, до семи цифр в пределах сорока девяти суток uptime; принимающей стороне хватает их, чтобы отличить живой узел от замолчавшего и увидеть перезапуск. Календарные штампы — из DS3231, разобранного в уроке про часы: тот же ключ ts, значение — unixtime, восемь-десять цифр. Секунды против миллисекунд — первый спор, который я проиграл документации, а выиграл журналом: семь цифр вместо десяти на каждом показании — это сотни байт в сутки.
t и h — числа с одним десятичным знаком: DHT22 отдаёт шаг в 0,1, а писать 23.437500 значит печатать мусорные цифры и платить за них в каждой телеграмме; я округляю до десятых до записи в документ. vcc — питание узла в милливольтах целым числом: делитель на A0 или измерение шины. Для батарейного узла это самое говорящее поле — сервер видит подсевшую батарейку раньше, чем узел замолчит навсегда. Целым, а не с точкой: три тысячи восемьсот семьдесят два милливольта — четыре байта печати, а с плавающей точкой — вдвое больше. Тот же счёт применим к влажности: целое число шага датчика честно и дёшево; десятую долю процента никто не читает с графика.
Почему плоско: каждый вложенный объект платит слотами пула за сам факт этажа — член вложенной группы дороже члена верхнего уровня, это чувствовали все, кто строил этажные пакеты. Плоский верх — самый дешёвый формат: пять полей, ноль вложенности, и принимающей стороне не нужно ходить по дереву ради двух чисел. Если состав подрастёт — добавляйте одну группу, но не лес: шестое поле уложит буфер впритык, и об этом ниже.
| Поле | Тип значения | Цена в печати |
|---|---|---|
| device | const char* | 19 байта: ключ 8, двоеточие, "dacha-1" с кавычками и запятая — длинное имя дороже ключа |
| ts | unsigned long, секунды | 13 байта при семи цифрах; миллисекунды добавили бы три — на каждом показании |
| t, h, vcc | float до десятых и целые | 26 байта на троих: числа всегда короче строк того же смысла |
| Телеграмма целиком | плоский объект | 56 байт печати, буфер — 57 с учётом ноль-терминатора |
Пятьдесят шесть байт на типовом показании — при 9600 бод это примерно шестьдесят миллисекунд эфира раз в минуту: меньше сотой доли процента канала даже у медленного UART. В буфер Serial на 64 байта строка влезает впритык, но я на него не рассчитываю: печать идёт в собственный char-буфер, где запас виден в константе, а не в удаче. Приёмник на другом конце получает предсказуемую строку: серверу не надо парсить ни рамку, ни разделители — весь протокол это один JSON и перевод строки.
С числами в телеграмме есть ловушка на стороне печати, и деление достаёт её первой. Посчитали напряжение с делителя как analogRead(A0) * 5.0 / 1023.0 — и в печать вместо аккуратных двух с половиной вольт едет 2.5024437927663734: двоичная дробь не кончается на втором знаке ни в симуляторе, ни на плате. Хвост едет в каждой телеграмме и платит байтами за разряды, которые никто не читает с графика. Лечение стоит копейки и уже стоит в скетче ниже: считайте в целых единицах, которые нужны принимающей стороне, — милливольтах, а не вольтах, — и округляйте до шага датчика до записи в документ, а не после. Тогда печать показывает 23.4, а не биографию двоичного представления.
Буфер, планировщик и отладка
Сериализация в char-буфер не зовёт malloc и не фрагментирует кучу — для батарейного узла, живущего месяцами, это главное отличие от печати в String. Приёмы печати в массив разобраны в уроке про serializeJson в char-буфер; здесь добавляется предохранитель: размер считается как measureJson(doc) + 1 — метод не считает ноль-терминатор, — и буфер растёт в константе, а не по ощущениям. Проверка стоит два вызова и экономит вечер с осциллографом.
Планировщик — миллис-сравнение в начале loop: прошло PERIOD с прошлой отправки — собрать и отравить, никакой delay, кнопка D2 шлёт вне очереди. Заодно кнопка включает pretty-печать: двухэтажную красоту и её цену мы считали в уроке про serializeJsonPretty — там она обходится дороже, чем здесь: плоская телеграмма пухнет меньше вложенного пакета, и для отладки по кнопке этого достаточно.
#include <ArduinoJson.h>#include <DHT.h> #define DHTPIN 2#define DHTTYPE DHT22 DHT dht(DHTPIN, DHTTYPE);JsonDocument doc; // v7: ёмкости нет, пул растёт сам const unsigned long PERIOD = 60000UL; // раз в минутуunsigned long lastSend = 0;char buf[96]; // печать — 56 байт, запас в полтора раза void otpravitTelegrammu() { doc.clear(); doc["device"] = "dacha-1"; doc["ts"] = millis() / 1000; // секунды с включения doc["t"] = (int)(dht.readTemperature() * 10.0) / 10.0; // шаг датчика 0,1 doc["h"] = (int)(dht.readHumidity()); doc["vcc"] = map(analogRead(A0), 0, 1023, 3000, 4200); // делитель на A0, мВ // Предохранитель: measureJson не считает ноль-терминатор — берём +1 int need = measureJson(doc) + 1; if (need > (int)sizeof(buf)) { Serial.print("telegramma vyros: nuzhno "); Serial.print(need); Serial.println(" bajt, uvelichite bufer"); return; } size_t n = serializeJson(doc, buf, sizeof(buf)); if (n == 0) { Serial.println("pechat ne sostoyalas: bufer mal"); return; } Serial.println(buf); // {"device":"dacha-1","ts":184,"t":23.4,"h":56,"vcc":3872}} void setup() { Serial.begin(9600); dht.begin();} void loop() { if (millis() - lastSend >= PERIOD) { lastSend = millis(); otpravitTelegrammu(); }}Разбор скетча короткий. Буфер девяносто шесть при печати в пятьдесят шесть — запас в полтора раза: ts подрастает с каждым днём uptime, а однажды добавится и шестое поле, и предохранитель обязан это пережить, а не молча отдать ноль. Округление t до десятых делит целочисленно: умножили на десять, отрезали дробную часть, вернули назад — тот же трюк честно работает и на AVR, и в симуляторе. map() с делителем переводит показание A0 в милливольты: на настоящем узле это два резистора по десять килоом и одна строчка калибровки по мультиметру.
История про предохранитель — моя. Телеграмма жила в буфере на шестьдесят четыре байта, ровно до дня, когда я вписал шестое поле press: serializeJson вернул 0, печать не состоялась, Serial замолчал, а я два вечера звонил провайдеру и переставлял блок питания. Диагноз — одна строка measureJson перед отправкой. С тех пор молчание узла проверяется первым же предохранителем: не влезло — печатай причину в отладочный канал, а не молчи.
Частота отправки — самая дешёвая из оптимизаций, и тут моя позиция жёстче, чем по буферам. Раз в минуту — тысяча четыреста сорок показаний в сутки, и каждое тащит на себе все пятьдесят шесть байт; подняв частоту до десяти секунд, вы умножаете на шесть эфир, пробуждения и расход батарейки, а график теплицы за эти десять секунд не меняется ни на пиксель: постоянная времени грунта — часы, а не секунды. Прежде чем крутить PERIOD, я задаю один вопрос: что нового увидит принимающая сторона? Ответа нет — остаётся минута. Исключение — скачок сразу на два шага датчика: телеграмма уходит вне очереди, как по кнопке в симуляторе; а чтобы тишину не путали со смертью, раз в десять минут уходит heartbeat-показание.
Телеграмма вживую
В симуляторе период укорочен до пяти секунд, чтобы отправку дождаться; на плате возвращается 60000UL. Ручка A0 играет делитель батарейки: подвиньте её — vcc в следующей телеграмме поплывёт от трёх тысяч до четырёх тысяч двухсот милливольтов, и серверная логика «сел аккумулятор» напрашивается сама. DHT на D2 крутится ползунками температуры и влажности — показания уходят такими, какие вы выставили, с честным шагом в десятую градуса.
Кнопка D2 — внеочередная телеграмма с pretty-печатью и обеими длинами рядом: компактная и красивая. Разница на плоской телеграмме — примерно в полтора раза; на вложенных пакетах красивая печать обходится дороже, о чём в разделе есть отдельный урок. LED на D12 вспыхивает на каждое отправление — глазами видно, что планировщик жив, а не зависит от Монитора порта.
- 1. Телеграмма печатается в 56 байт. Сколько держать под неё в char-буфере?
- 2. serializeJson(doc, buf, sizeof(buf)) вернул 0. Что это значит?
- 3. Как слать телеграмму раз в минуту, не блокируя loop?
- 4. Компактная телеграмма — 56 байт. Сколько примерно займёт её pretty-печать?
- 5. Что кладём в поле ts?
- Телеграмма из пяти плоских полей device/ts/t/h/vcc печатается в 56 байт; каждый этаж вложенности добавил бы слоты пула
- Числа округляйте до честного шага датчика: 23.4 вместо 23.4375 — минус четыре байта на каждом показании
- Буфер — measureJson(doc) + 1; serializeJson в char-массив не зовёт malloc и возвращает 0, если не влез
- Отправка — по millis-планировщику без delay: кнопка шлёт вне очереди и включает pretty для отладки
- Pretty на плоской телеграмме — примерно полтора размера компактной; на вложенных пакетах разница доходит до трёх раз
Частые вопросы по теме урока
- Телеграмма печатается в 56 байт. Сколько держать под неё в char-буфере?
- measureJson возвращает длину печати без ноль-терминатора. Буфер — measureJson(doc) + 1, а на практике — с запасом на будущие поля. 57 минимум: measureJson не считает ноль-терминатор
- serializeJson(doc, buf, sizeof(buf)) вернул 0. Что это значит?
- Возврат 0 при печати в char-массив — молчаливый отказ: библиотека не станет писать обрезок. Проверяйте возврат и печатайте причину в отладочный канал. Телеграмма не влезла в буфер: печать не состоялась
- Как слать телеграмму раз в минуту, не блокируя loop?
- Миллис-планировщик: запомнили момент отправки, сравнивали разницу каждый проход loop. Кнопки, датчики и мигание LED между телеграммами продолжают жить. Сравнение millis() - lastSend >= PERIOD в начале loop
- Компактная телеграмма — 56 байт. Сколько примерно займёт её pretty-печать?
- Переводы строк и отступы стоят на каждом поле. На плоской телеграмме — примерно полтора размера; с вложенностью разница растёт до трёх раз. Около 80–90 байт: плоский объект пухнет примерно в полтора раза
- Что кладём в поле ts?
- Секунды экономят цифры в каждой телеграмме и переживают долгий uptime; календарный штамп — тот же ключ с unixtime из RTC, если часы на плате есть. Секунды с включения millis()/1000 или unixtime из DS3231