Метеостанция на даче первый сезон отправляла показания по одному: раз в пятнадцать минут — строка с текущей температурой. За сутки набегало девяносто шесть пакетов, радиоканал между домом и теплицей периодически чихал, и в истории появлялись дыры: сервер честно рисовал то, что дошло, а что не дошло — темнота. Усреднять по кривой с пропусками я уже не мог: график врал, а решения по нему я принимал всерьёз.
Лекарство нашлось в самой структуре протокола: копить точки в плате и отдавать историю одним пакетом. Один пакет — одна гарантия доставки, одна отметка времени, один разбор на приёмнике. Число отправок упало с девяноста шести до двадцати четырёх, а потом до шести, когда я стал слать пачку сразу за четыре часа. Бонусом пропали дубли: точка, отправленная дважды из-за ретрая, больше не появлялась на графике ступенькой.
Идея пачки старше меня: так пишут логи, так шлют телеметрию спутники. Но в ардуинном мире она упирается в две вещи — память платы и дисциплину приёма. Первую мы посчитаем в байтах прямо здесь, вторую — отдельно: шестисотбайтовый пакет не пролезет через 64-байтовый буфер приёмника, если тот не читает порт непрерывно. Обе грабли обходятся, но обе — честно, с числами.
В этом уроке — упаковка истории в JSON и обратная распаковка: сколько байтов стоит точка, как вытащить из пакета минимум, максимум и среднее и как нарисовать график прямо в мониторе порта, не имея ни экрана, ни сервера.
Стоимость точки и экономия на времени
Точка истории — число в массиве: 20.4. В пакете она стоит около шести байтов вместе с запятой: цифры, точка, разделитель. Сотня точек с одним знаком после запятой — порядка шестисот байтов текста. При 9600 бод один байт идёт около миллисекунды, так что пачка за сутки занимает канал примерно шестьсот миллисекунд — раз в час, а не каждую четверть часа. Тот же пакет на 115200 бод проходит за пятьдесят с небольшим миллисекунд. Числа маленькие, но именно они решают, влезет ли протокол в канал дачного радиомодуля.
Первая экономия — время. Отметку для каждой точки можно выкинуть: если замеры идут строго по часам, i-я точка массива и есть i-й час с начала пакета. Время знают и отправитель, и приёмник — им достаточно договориться о точке отсчёта. Сотня отметок времени — это сотни байтов, которые превращаются в одно слово в комментарии к пакету.
Вторая экономия — точность. Для графика за сутки хватает одного знака после запятой: 20.4 против 20.4173. Смелый вариант — целые десятые доли одним байтом: 204 значит 20,4, и сто точек сжимаются до ста байтов. Но это уже свой двоичный протокол с легендой, а JSON выбирался в том числе за то, что пакет читается без легенды: собирая свои документы через serializeJson, я ценил это не раз.
Документ под сотню точек — уже всерьёз для Uno. В седьмой версии библиотеки документ растёт сам, и мы разбирали его повадки в уроке про JsonDocument; на приёме проверяйте doc.overflowed(), на отправке — полезно заранее посмотреть measureJson(doc): длину пакета в байтах без терминатора.
Частота отправки — компромисс, который стоит посчитать до прошивки, а не после. Пачка за сутки — шестьсот байтов раз в час: канал свободен, память целая. Пачка за неделю по пятнадцать минут — уже четыре килобайта: на Uno такой документ с живыми данными не соберётся. Моё правило для Uno — пачки до двухсот точек; всё длиннее — повод переезжать на ESP32 с её сотнями килобайт ОЗУ или резать историю на страницы по часам.
#include <ArduinoJson.h> const int N = 100; // 100 точек историиfloat hist[N]; // заполните своими замерами, например по millis-интервалу JsonDocument doc; // v7 растёт сам; в v6 — DynamicJsonDocument<1200> void setup() { Serial.begin(9600); // Сборка: массив внутри документа JsonArray h = doc["h"].to<JsonArray>(); // v6: doc.createNestedArray("h") for (int i = 0; i < N; i++) { h.add(hist[i]); } if (doc.overflowed()) { // v7: хватило ли памяти Serial.println("Dokument ne vlez v pamyat"); return; } Serial.print("Bajt paketa: "); Serial.println(measureJson(doc)); // длина ДО печати serializeJson(doc, Serial); // один пакет — один массив Serial.println();} void loop() {} // Приёмник: собираем строку до конца пакета, потом разбор —// и цикл как в симуляторе: float v = doc["h"][i];Разбор на приёмнике симметричен сборке: deserializeJson, проверка кода, цикл по индексам. Из пакета достаются три числа, ради которых история и хранилась: минимум, максимум и среднее. Минимум и максимум — границы вилки: ниже минимума температура не опустилась, выше максимума не поднялась. Среднее показывает климат в целом и заодно проверяет целостность: если среднее уехало от привычного на градус без явного похолодания — ищите дыру в данных. Вилка мин-макс пригодится и валидатору: следующий замер вне вилки — повод усомниться в канале, а не в погоде.
Бар-чарт в мониторе — мой любимый способ посмотреть на историю без сервера и без экрана: по строке на час, длина полоски решёток считается через map() от минимума до максимума пачки. Печать занимает двадцать четыре строки и полсекунды канала, а сутки видны с одного взгляда: где рассвет, где пик, где провал. Никакой магии в рисовании — цикл, печать символов, перенос строки; на плате этот же цикл печатает Serial, и график возникает прямо в мониторе IDE.
Приём длинного пакета — отдельная дисциплина, и об этом дальше.
| Точек в массиве | Байтов в пакете | Канал 9600 бод | Канал 115200 бод |
|---|---|---|---|
| 1 | 6 Б | 6 мс | меньше 1 мс |
| 24 (сутки почасово) | около 150 Б | около 160 мс | около 14 мс |
| 100 | около 610 Б | около 640 мс | около 55 мс |
| 672 (неделя по 15 минут) | около 4 КБ | около 4.2 с | около 0.36 с |
График без экрана: бар-чарт в мониторе
В симуляторе — те же грабли в миниатюре: двадцать четыре точки за сутки, по одной на час. Движок разбирает строку в документ, и значения достаются в цикле: doc.h[i] — точечная форма; на плате она равнозначна doc["h"][i], но движок понимает только точку. Строка razbor= показывает, сошёлся ли разбор, — привычка из первого разбора работает и здесь. Собрать массив присваиванием движок не умеет — в симуляторе история приходит готовой строкой, а сборка пакета из живых замеров лежит в code-блоке для платы: там ей помогает createNestedArray или to<JsonArray>.
Перед графиком скетч печатает меру пакета: bayt_json — те самые байты, которые канал при 9600 потащит около ста двадцати миллисекунд. Потом минимум, максимум и среднее — и столбчатый график: полоска решёток тем длиннее, чем жарче час. Двадцать четыре строки — и сутки видны с одного взгляда, без экрана и без сервера. Тот же приём работает и на плате — Serial.print в цикле решает всё.
Шкала бара — самое интересное место скетча. map() работает с целыми, поэтому температуры я умножаю на десять: 17.6 превращается в 176, и шаг полоски — десятая градуса, а не целый градус. Диапазон шкалы не фиксирован, а берётся из данных: минимум и максимум этой же пачки. Такой график честен и к трём градусам размаха, и к тридцати — бар в любом случае растянется на весь диапазон, а мелкая рябь у пола останется заметной.
- Сдвинуть пик. Поменяйте значение в полдень на 25.9: максимум, среднее и бар-чарт пересчитаются, а bayt_json вырастет на байт — лишняя цифра.
- Ловушка двух мест. Добавьте двадцать пятую точку в строку, но оставьте границу цикла 24: новая точка молча не попадёт в статистику. Так рождается рассинхрон данных и счётчика.
- Растянуть шкалу. Поменяйте в map() последний аргумент с 20 на 40: график станет вдвое длиннее, пики — виднее, строки — шире.
- На настоящей плате. Заполните hist[100] замерами, соберите пакет скетчем из code-блока и сравните measureJson на плате с bayt_json из симулятора — числа сойдутся до байта.
- История одним пакетом надёжнее россыпи: одна доставка, одна отметка времени, один разбор на приёмнике
- Точка с одним знаком после запятой стоит около 6 байтов; сотня точек — около 610 байтов и около 640 мс при 9600 бод
- Отметки времени выкидываются, когда шаг замера фиксирован: i-я точка и есть i-й час
- measureJson даёт длину пакета до отправки, doc.overflowed() — признак того, что документу не хватило памяти
- Приёмник обязан читать порт непрерывно: буфер Serial на Uno — 64 байта, шестисотбайтовый пакет в него не помещается
- 1. Сколько примерно байтов занимает в пакете одна точка истории с одним знаком после запятой?
- 2. Замер идёт строго каждый час. Где в пакете отметка времени для i-й точки?
- 3. Что вернёт measureJson(doc) перед отправкой пакета?
- 4. Пакет в 600 байтов идёт по каналу 9600 бод примерно...
- 5. Приёмник на Uno со 64-байтовым буфером спал, пока приходил 600-байтовый пакет. Что получит deserializeJson?
Пачка вместо россыпи: один пакет, одна доставка, один разбор — и график, который видно прямо в мониторе порта. Для вашего протокола пачка остаётся обычным сообщением: маркеры начала и конца никто не отменял, и приёмы из уроков про Serial работают без изменений. Дальше история может уехать дальше: на сервер HTTP-запросом с телом POST или в брокер через MQTT — пакет из этого урока ляжет туда без изменений, только заголовок Content-Type станет application/json. Домашнее задание с платой: накопите за сутки хотя бы двадцать четыре замера по millis-интервалу, соберите массив и отправьте пачкой; байты сверяйте — measureJson и на плате честный.
Частые вопросы по теме урока
- Сколько примерно байтов занимает в пакете одна точка истории с одним знаком после запятой?
- Цифры, точка и запятая-разделитель — около шести байтов. Из этого и складывается цена пакета: сотня точек — порядка шестисот байтов, неделя по пятнадцать минут — уже четыре килобайта. Около 60 байтов
- Замер идёт строго каждый час. Где в пакете отметка времени для i-й точки?
- При фиксированном шаге время выкидывается из пакета: i-й час знает и отправитель, и приёмник — им достаточно договориться о точке отсчёта. Экономия — сотни байтов на сотне точек. Нигде: время определяется позицией в массиве
- Что вернёт measureJson(doc) перед отправкой пакета?
- measureJson считает длину документа в байтах без терминатора. Полезно до печати: сразу видно, влезет ли пакет в канал и в буфер приёмника — и сколько секунд он займёт при вашем бодрейте. Длину пакета в байтах
- Пакет в 600 байтов идёт по каналу 9600 бод примерно...
- При 9600 бод байт идёт около миллисекунды: 600 байтов — около 600 мс. На 115200 бод тот же пакет занимает около 55 мс — в десять раз быстрее, потому и монитор порта в IDE обычно держат на быстром бодрейте. 600 мс
- Приёмник на Uno со 64-байтовым буфером спал, пока приходил 600-байтовый пакет. Что получит deserializeJson?
- Хвост, не уместившийся в буфер, теряется: библиотеке не хватит данных, и разбор оборвётся. Читайте порт непрерывно и собирайте строку до конца пакета — разбирайте уже собранное. Оборванный пакет: разбор не сойдётся