Роутер перезагрузился в 01:40, платы вернулись к 09:20 — утренний график теплицы висел с дырой в восемь часов. В уроке про reconnect я выбрасываю показания при отвале и называю это честным решением: живому графику не нужна очередь устаревших отсчётов. Честным — до той ночи, когда в доме поселился расчёт отопления по средним за час: каждая дыра ломала сумму, а среднюю температуру пропавшей ночи не измеришь задним числом. Пришлось строить копилку.
Выбор «копить или выбрасывать» решается одним вопросом: можно ли вернуть значение потом. Температура — мгновение, после возврата связи она невосстановима; освещённость и влажность — то же самое. Импульсы счётчика не теряются никогда: в уроке про счётчик воды отвал переживает одно растущее число, которое уезжает в брокера одним сообщением после reconnect. Между этими сортами живёт третий случай: нужна история точками — по одной на каждый интервал, с меткой времени. Для него кольцевая копилка и существует.
Здесь разбираю копилку в RAM: что класть в неё, сколько влезает на Uno, что затирать при переполнении и почему умирает самая старая запись. Темп, с которым копилка раздаётся после возврата сети, — тема следующего урока; в этом хватает накопления.
Что копить: мгновение, агрегат, накопление
Три сорта показаний я держу в голове как три разные судьбы. Мгновения — температура, влажность, освещённость — при отвале выбрасываю: значение описало прошлое мгновение и на живом графике после возврата связи выглядит как укол в прошлое. Накопления — импульсы, литры, киловатты — в копилку точек не кладу: они живут одним растущим счётчиком, и точность этого способа я проверял месяцами на водомере. А вот агрегаты — среднее за час, максимум за ночь, минимум за утро — коплю в буфере: сутки дают двадцать четыре часовые точки против тысячи четырёхсот минутных, и разница между этими объёмами решает, влезет ли история в RAM вообще.
Секрет агрегатов в том, что они считаются на плате без сети. Среднее за час копится по millis-таймеру: сумма отсчётов и счётчик отсчётов, деление при закрытии часа. Форма та же, что у обычного millis-цикла из урока про millis вместо delay, — просто вместо публикации на каждом тике происходит накопление. Сама техника массива-журнала с головкой и счётчиком разобрана в уроке про массивы и журналы; здесь я натягиваю её на сетевой случай: пишем не всегда, а только когда сети нет.
Четвёртый гость копилки — события: дверной контакт сработал в 03:12, кто-то нажал кнопку, датчик протечки крикнул. Событие не повторится и не восстановится, его место в копилке вечное, а формат — та же точка с меткой, только вместо значения код происшествия. Отличие в правилах выживания: события не должны затираться кольцом, потому что «затёр самое старое» на журнале происшествий звучит как проигранное дело. На узле теплицы мой расклад такой: сорок точек под историю температуры и отдельные двадцать под события — числа с потолком, но с запасом, и переполнение кармана событий пишется в журнал как происшествие само по себе.
#include <WiFi.h>#include <PubSubClient.h> WiFiClient net;PubSubClient client(net); const int PIN_POT = A0; // датчик-заглушка const int CAP = 60; // потолок копилки: 60 точекlong bufTs[60]; // метка точки, секунды от включенияint bufT[60]; // значение, десятые градусаint head = 0; // куда пишем следующимint count = 0; // сколько лежит (до CAP)int overwrites = 0; // сколько раз затёрли старую unsigned long lastSample = 0;unsigned long lastPub = 0; void addReading(long ts, int t) { bufTs[head] = ts; bufT[head] = t; head = head + 1; if (head >= CAP) { head = 0; } // кольцо: головка по кругу if (count < CAP) { count = count + 1; } else { overwrites = overwrites + 1; // самая старая запись умерла }} void setup() { Serial.begin(115200); WiFi.begin(WIFI_SSID, WIFI_PASS); client.setServer(MQTT_SERVER, 1883); if (client.connect("teplica-buf")) { client.subscribe("teplica/cmd"); }} void loop() { client.loop(); unsigned long now = millis(); int t = analogRead(PIN_POT) / 10 - 5; // условные десятые градуса if (now - lastSample >= 60000) { // опрос раз в минуту lastSample = now; if (client.connected()) { // сеть есть: свежее значение уходит сразу, копилка молчит } else { addReading(now / 1000, t); // отвал: точка в кольцо } } if (client.connected() && now - lastPub >= 60000) { lastPub = now; char msg[24]; snprintf(msg, sizeof(msg), "{\"t\":%d.%d}", t / 10, abs(t % 10)); client.publish("home/teplica/temp", msg); }}Бюджет: шесть байт на точку и потолок Uno
Бюджет памяти — вот где копилка подписывает себе приговор. Запись в скетче выше — это long на метку (четыре байта) и int на значение (два): шесть байт на точку. Шестьдесят точек — триста шестьдесят байт; на Uno с её двумя килобайтами RAM это почти пятая часть всей памяти, и я бы не пошёл выше без веской причины. При минутном опросе шестьдесят точек — час истории: короткий флап роутера переживается с полным архивом, ночь без сети — нет. На ESP32 расклад другой: сотни килобайт позволяют держать сутки минутных точек, но раздача такого архива — сорок с лишним килобайт трафика — уже вопрос к следующему уроку, а не к памяти.
Второй этаж копилки — EEPROM, и здесь решают два числа: три миллисекунды на запись ячейки и примерно сто тысяч перезаписей до износа. Ежеминутная запись убивает ячейку за шестьдесят девять дней — арифметика беспощадная, поэтому в EEPROM едут суточные сводки: двадцать четыре часовых средних, по кругу, со счётчиком позиции, чтобы износ распределялся равномерно. На ESP32 та же идея живёт в NVS через Preferences: ресурс выше, принцип тот же — редкие агрегаты, не поток. Копилка в RAM и сводка в EEPROM отлично сосуществуют: RAM держит свежие минуты, EEPROM — историю суток на случай долгого отвала.
Метка времени в записи — второй тонкий момент. millis() умирает вместе с питанием и не знает про настенные часы: метка «секунды от включения» живёт только внутри одной прошивки. Для честной истории нужен RTC: как я привязываю записи журнала к DS3231 и почему получасовой дрейф портит сортировку, разобрано в уроке про метки времени. Если RTC в узле нет, кладите в запись «минуты от начала отвала» — счётчик начинается с нуля при первом пропущенном опросе, и дашборд растянет историю по относительной шкале, не притворяясь, что знает час.
Шаг опроса задаёт глубину истории при том же объёме памяти. Шестьдесят точек при минуте — час; при десяти секундах — всего десять минут; при пяти минутах — пять часов. Часовой расчёт отопления не заметил бы и пятиминутного шага, но расследовать ночной сквозняк по пяти минутам бессмысленно: фронт холода размажется. Поэтому на теплице стоят шестьдесят точек по минуте, а не шестьсот по десяти секундам: память одна и та же, а глубина нужнее частоты. Шаг подписывайте прямо в комментарии к CAP — через полгода никто, включая автора, не помнит, что там хранилось.
Кольцо против стопа: что затирать при переполнении
Затирать при переполнении я решаю самое старое — и это выбор, а не аксиома. Кольцо сохраняет свежую историю: после долгого отвала у вас есть последние N точек, дашборд и так помнит более старое из retained-кэша — механику этой памяти брокера я разбирал в уроке про retained-статус. Обратный режим — «стоп»: копилка заполнилась и молчит, зато первые точки отвала гарантированы; он нужен для разбора аварий, где важное — начало беды. Я ставлю кольцо везде, кроме узлов-свидетелей, которые должны отдать именно начало происшествия. Переключение режима — одна логическая переменная в addReading, и на стенде его удобно менять командой из монитора, как в симуляторе ниже.
Первая моя версия не считала затираний вовсе: count доехал до потолка и молча застрял, а overwrites не существовало — «всё работает», пока однажды после суток в отвал в копилке не оказалось шестьдесят одинаковых последних минут и ноль намёков, что было раньше. Счётчик перезаписей — три строки кода и единственный честный ответ на вопрос «сколько истории я потерял». Он же — датчик здоровья сети: сотня затираний за ночь говорит о том, что копилка не справляется, и пора либо урезать шаг, либо выводить сводки в EEPROM.
Место копилки в loop — последнее важное решение. Запись в буфер стоит микросекунды и не должна ничего ждать: ветка addReading сидит рядом с опросом датчиков, вне зависимости от состояния сети, — тогда даже скетч с блокирующим reconnect из старой версии не теряет точки, пока стоит в connect. Именно так копилка страхует слабости всей остальной машины связи: сеть может ломаться как угодно, кольцо между двумя оборотами loop пишет тихо и без разрешения. Если у вас запись в буфер спрятана под if (online) — вы её похоронили: отвал она как раз и переживает.
| Способ пережить отвал | Сколько держит | Цена записи | Когда мой выбор |
|---|---|---|---|
| Кольцо в RAM | минуты-часы: 6 байт на точку, 60 точек = 360 байт | микросекунды, ресурса нет | частые короткие отвалы, история точками |
| EEPROM / NVS | годы, переживает питание | около 3 мс и ~100 тысяч перезаписей на ячейку | суточные сводки по кругу, счётчики |
| Выбрасывание | ничего | ноль байт | живые графики в реальном времени, репродукция не нужна |
- 1. Запись копилки — long на метку и int на значение. Сколько байт даёт одна точка?
- 2. Кольцевая копилка переполнилась. Какую запись затирает новая точка?
- 3. Какое показание я выбрасываю при отвале, не кладя в копилку?
- 4. Почему в записи копилки нельзя держать String с готовым JSON?
- 5. millis() положен в запись как метка времени. Что он даёт после возврата питания?
- Копить или выбрасывать решает вопрос «восстановимо ли значение потом»: мгновения выбрасываю, агрегаты и события коплю.
- Запись 6 байт (long-метка плюс int-значение), 60 точек — 360 байт: на Uno это потолок разумного, на ESP32 можно сутки.
- Кольцо затирает самое старое — свежая история нужнее, старое дашборд помнит из retained; режим «стоп» — для разбора начала аварий.
- В записи только фиксированные типы: String фрагментирует кучу и роняет Uno за сутки, сериализация — на выходе.
- millis() в метке — секунды от включения; для настенного времени нужен RTC или честная метка «минуты от начала отвала».
Частые вопросы по теме урока
- Запись копилки — long на метку и int на значение. Сколько байт даёт одна точка?
- long занимает четыре байта, int — два; вместе шесть байт на точку. Шестьдесят точек — триста шестьдесят байт, почти пятая часть RAM Uno. Шесть байт
- Кольцевая копилка переполнилась. Какую запись затирает новая точка?
- Кольцо пишет поверх самой старой записи: дашборд и так помнит более старое из retained-кэша, а свежая история нужнее. Для разбора аварий начала беды есть режим «стоп». Самую старую
- Какое показание я выбрасываю при отвале, не кладя в копилку?
- Минутная температура — мгновение: она невосстановима, но и не нужна живому графику после возврата связи. Средние и максимумы копятся как агрегаты, счётчики живут одним числом. Минутную температуру
- Почему в записи копилки нельзя держать String с готовым JSON?
- Каждая строка — отдельный кусок кучи; после длинных отвалов фрагментация роняет плату. В записи — только фиксированные типы, сериализация одним snprintf в момент публикации. Фрагментация кучи за сутки доводит malloc до нулей
- millis() положен в запись как метка времени. Что он даёт после возврата питания?
- millis() не знает про настенные часы и умирает с питанием. Честные метки даёт RTC: DS3231 и привязка журнала к реальному времени разобраны в уроке про метки времени. Только секунды от включения, ноль после перезагрузки