Сервер строил график температуры за час, а узел слал по одной точке раз в минуту. График дрожал от каждого чиха канала: связь моргнула на полчаса — и в истории дырка, приёмник крутил карусель из мелких пакетов, а в журнале за неделю накопились тысячи записей вида «доехало, но с опозданием». Всё это время платформа узла простаивала: на Uno две тысячи байт ОЗУ, и шестьдесят последних измерений в неё помещаются с запасом.
Перевернул схему: узел копит точки в кольцевом буфере и раз в час отдаёт один JSON с массивом из шестидесяти пар {t, v}. Связь открылась один раз — приехал целый час истории; порвалась — час уйдёт заново, но дырки внутри пакета не бывает. Пятиминутная телеграмма из плоских полей осталась в уроке про телеграмму датчиков — она шла бы и дальше, но у истории свои правила, и их стоит проговорить числами. Кольцевую запись показаний я уже держал в уроке про журнал датчика; здесь добавляется второй этаж — упаковка буфера в JSON.
План: createNestedArray и сборка в цикле, арифметика размера — почему пакет выходит на 900-1200 байт, ловушка long при среднем значении, и честная таблица, когда массив уместен, а когда честнее слать по одной.
Кольцевой буфер и createNestedArray
Буфер — два массива tBuf[60] и vBuf[60], указатель head и счётчик n. Каждое измерение пишется в head, указатель сдвигается по модулю 60, самый старый элемент затирается сам собой. Память платится один раз и вперёд: шестьдесят точек по четыре байта float и два байта int — триста шестьдесят байт, шестая часть ОЗУ Uno. Взамен узел всегда готов ответить на «дай последний час» — без чтения SD, без запросов наружу, история лежит в ОЗУ и живёт до отключения питания.
Упаковка в JSON начинается с массива в корне: JsonArray pts = doc.createNestedArray("pts"); — метод заводит массив и возвращает ссылку, дальше в цикле по буферу JsonObject p = pts.createNestedObject(); p["t"] = tBuf[idx]; p["v"] = vBuf[idx];. В седьмой версии те же вызовы записываются как doc["pts"].to<JsonArray>() и pts.add<JsonObject>() — старые имена остались рабочими псевдонимами, и переезд на новые разобран в уроке про миграцию v6 в v7. Для скалярного массива без объектов короче: doc["v"].to<JsonArray>() и add() на каждый элемент.
Почему объект на точку, а не два параллельных массива t[] и v[]: пара едет одним куском, и принимающая сторона не собьётся, если однажды в точке появится третье поле — объект расширяется без смены формата. Цена решения — по слоту пула на каждый объект: шестьдесят объектов дороже шести скаляров, но в пределах килобайта это плата за читаемость, а не за выживание. Считать цену до прошивки всё равно нужно, и ниже — вся арифметика.
#include <ArduinoJson.h> const int N = 60; // окно: 60 точек = час при минуте на точкуfloat tBuf[N];int vBuf[N];int head = 0;int n = 0; const unsigned long PERIOD_TOCHKI = 60000UL; // точка раз в минутуconst unsigned long PERIOD_DAMP = 3600000UL; // дамп раз в часunsigned long lastTochka = 0;unsigned long lastDamp = 0; float readT() { // здесь ваш датчик: DHT22, DS18B20, термистор; для примера — шкала от A1 return map(analogRead(A1), 0, 1023, -100, 400) / 10.0;} void dobavitTochku(float t, int v) { tBuf[head] = t; vBuf[head] = v; head = (head + 1) % N; if (n < N) n++;} void otpravitPaket() { if (n == 0) return; JsonDocument doc; // v6: DynamicJsonDocument(1400) doc; doc["device"] = "dacha-1"; doc["n"] = n; JsonArray pts = doc.createNestedArray("pts"); // v7: JsonArray pts = doc["pts"].to<JsonArray>(); long sumV = 0; // long, не int: 60 * 1023 = 61380 > 32767 float sumT = 0.0; for (int i = 0; i < n; i++) { int idx = head - n + i; // от самого старого к свежему if (idx < 0) idx += N; JsonObject p = pts.createNestedObject(); // v7: JsonObject p = pts.add<JsonObject>(); p["t"] = tBuf[idx]; p["v"] = vBuf[idx]; sumV += vBuf[idx]; sumT += tBuf[idx]; } doc["avg_t"] = (int)(sumT / n * 10.0) / 10.0; // среднее до 0,1 doc["avg_v"] = sumV / n; // целое среднее от long Serial.print("razmer paketa: "); Serial.print(measureJson(doc) + 1); // +1 под ноль-терминатор Serial.println(" bayt"); serializeJson(doc, Serial); Serial.println();} void setup() { Serial.begin(9600);} void loop() { if (millis() - lastTochka >= PERIOD_TOCHKI) { lastTochka = millis(); dobavitTochku(readT(), analogRead(A0)); } if (millis() - lastDamp >= PERIOD_DAMP) { lastDamp = millis(); otpravitPaket(); }}Арифметика пакета считается на пальцах и проверяется measureJson — оба числа должны сойтись. Точка {"t":21.4,"v":512} — восемнадцать знаков с кавычками и запятой; шестьдесят точек — примерно тысяча семьдесят, шапка с device, n и парой средних добавляет семьдесят-восемьдесят. Итог — от девятисот до тысячи двухсот байт в зависимости от длины чисел: температура с одной десятой и напряжение в пять цифр едут дороже целых. Замер показаний разобран в уроке про measureJson — здесь предохранитель тот же: мери до отправки, а не после молчания приёмника.
Эфир при 9600 бод — около секунды на пакет: раз в час это меньше трети процента канала, а на ESP32 с Wi-Fi вопрос исчезает вовсе. Сорок восемь графиков в сутки — по одному на каждый час, — и приёмник собирает их в базу без сборки мелких кусков. Сто точек в график укладывал соседний урок про график из 100 точек — там та же экономика, только история приходила готовой строкой; здесь мы её собираем сами.
Когда массив уместен, а когда нет — вопрос канала, а не вкуса. Массив берите, когда порция должна доехать целиком: график либо есть, либо нет, середины не бывает, и приемник не обязан склеивать обрывки. По одной точке шлите, когда связь рвётся часто и каждая точка обязана уехать сразу, пока окно живо, — и тогда честнее JSONL: одна строка-событие на показание, приёмник дописывает их в журнал по мере поступления, как разобрано в уроке про JSONL-журнал. Мой предел — окно в ОЗУ: буфер больше шестисот точек на Uno уже теснит стек, и история переехала бы на SD.
Поле n в шапке — страховка для приёмника. После старта узла окно неполное: сорок точек через сорок минут, график по ним честный, но формы дня ещё не видно. Приёмник различает полное окно и после-стартовое по n: меньше шестидесяти — рисует с оговоркой или ждёт. Без этого поля неполный час выглядит как полный, и график молча врёт формой — ловил такое на собственном дашборде дважды, пока не завёл n.
Среднее и переполнение
Ловушка сидит в безобидном среднем. Сумма шестидесяти показаний АЦП доходит до 60 × 1023 = 61380, а int на AVR — шестнадцать бит с потолком 32767: сумма молча уезжает в минус, и среднее при плюсовом дне получается отрицательным. График врёт на глазах, датчик ни при чём, а лечится всё одним словом: long sumV = 0; — потолок long в два миллиарда покрывает и окно в шестьдесят точек, и окно в шестьсот. Деление в конце остаётся целочисленным — doc["avg_v"] = sumV / n; — и это честное среднее по целым показаниям.
Та же болезнь живёт во всех местах, где накапливаются числа: секунды uptime, миллис-разности, счётчики пакетов. Правило у меня одно: любая сумма, в которую больше тридцати слагаемых, пишется в long сразу, а не по факту переполнения — ловить минус на графике дороже, чем напечатать четыре лишних байта в декларации. Проверьте и обратную сторону: на ESP32 int — тридцать два бита, и там переполнения нет ни при каком окне; скетч переносится между платами, а привычки суммирования — нет, поэтому long я ставлю и там, где он избыточен. Отдельная история с millis и его сорока девятью днями разобрана в соседнем разделе; здесь та же дисциплина на меньшем масштабе.
Моё мнение про частоту дампа: раз в час — и точка. Чаще — и канал, и база пухнут без пользы: температура за час меняется на доли градуса, а шестьдесят точек уже рисуют форму дня. Хуже реже — тоже можно, но окно в ОЗУ честнее показывать целиком: шестьсот точек уже теснят стек Uno, и это тот случай, когда историю пора переносить на SD, а не ужимать частоту.
| Способ отправки | Цена | Что при обрыве связи |
|---|---|---|
| По одной точке раз в минуту | 20-30 байт пакета на точку: рамка и ключи повторяются всегда | дырка в графике: точка потеряна вместе с сеансом |
| Массив из 60 точек раз в час | ~1100 байт пакетом; ~18 байт на точку, шапка — один раз | порция атомарна: график либо доехал целиком, либо его нет |
| JSONL: строка-событие на показание | ~20 байт строки, без рамки пакета | потерянные строки видны по пропускам штампов; журнал дописывается построчно |
| Гибрид: буфер + дамп по кнопке | те же ~1100 байт, но вне расписания | удобно для отладки: история выгружается на месте, не дожидаясь часа |
Про симулятор — честно: движок не строит вложенные массивы присваиваниями, createNestedArray на нём не оживает. Поэтому в симе пакет склеивается строкой из буфера и разбирается deserializeJson — байты у measureJson выходят ровно те же, что на плате собрал бы код-блок, и размер пакета в логе настоящий. Итерация по буферу, long-сумма, кольцевой указатель — всё крутится по-настоящему.
Что попробовать. Ручка A0 — датчик: каждые полсекунды точка ложится в буфер, LED коротко вспыхивает на каждой. Кнопка D2 — дамп: Serial покажет пакет целиком, число точек и его размер в байтах. Дождитесь шестидесяти точек — тридцать секунд — и сравните размер полного окна с половинным: рост почти линейный, и каждая цифра из таблицы выше на виду.
- 1. Точка {"t":21.4,"v":512} в печати пакета — это примерно…
- 2. Среднее по 60 показаниям АЦП считают в int. Что именно его ломает?
- 3. Что делает doc.createNestedArray("pts")?
- 4. Связь моргнула на середине отправки массива из 60 точек. Что с графиком?
- 5. Тот же массив в седьмой версии библиотеки заводится как…
- Кольцевой буфер tBuf[60]/vBuf[60] платит 360 байт один раз: узел всегда готов отдать последний час
- Сборка: doc.createNestedArray("pts") плюс createNestedObject в цикле; в v7 — to<JsonArray>() и add<JsonObject>()
- Точка {"t":21.4,"v":512} — 17-18 байт печати; 60 точек с шапкой — 900-1200 байт, меряем measureJson до отправки
- Сумма для среднего — только long: 60 × 1023 = 61380 переполняет int молча, среднее уезжает в минус
- Массив уместен, когда порция обязана доехать целиком; при ненадёжном канале честнее точки по одной или JSONL
Частые вопросы по теме урока
- Точка {"t":21.4,"v":512} в печати пакета — это примерно…
- Шестьдесят точек по восемнадцать знаков плюс шапка — тот самый диапазон 900-1200 байт. Считается на пальцах и проверяется measureJson до отправки. 17-18 байт: кавычки, ключи и запятые тоже платят
- Среднее по 60 показаниям АЦП считают в int. Что именно его ломает?
- int на AVR — шестнадцать бит. Сумма переполняется молча, среднее становится отрицательным при плюсовом дне. Лечение — long до цикла. Сумма 60 × 1023 = 61380 выше потолка int 32767 — уезжает в минус
- Что делает doc.createNestedArray("pts")?
- Ссылка нужна для цикла: на ней вызываются createNestedObject и add. В v7 то же пишется как doc["pts"].to<JsonArray>(). Заводит в корне документа массив pts и возвращает ссылку на него
- Связь моргнула на середине отправки массива из 60 точек. Что с графиком?
- Атомарность — главный аргумент массива. Обрыв стоит час истории, но не порождает полуграфика, который легко принять за настоящий. Порция атомарна: график либо есть целиком, либо его нет — шлём заново
- Тот же массив в седьмой версии библиотеки заводится как…
- Седьмая версия переименовала вызовы в to<JsonArray>() и add<JsonObject>(). createNestedArray продолжает работать с предупреждением о deprecated. doc["pts"].to<JsonArray>() — старые имена остались рабочими псевдонимами