Термометр в теплице показывал ровно одну цифру — текущую. В марте ночью на даче вырубили электричество, к утру внутри было плюс два вместо пятнадцати, и я узнал об этом по погибшей рассаде: экран честно показал те самые плюс два, но истории у него не было — скачок случился, когда все спали. Serial Plotter в браузере рисовал бы такую кривую всю ночь, но компьютер в теплице не живёт — график на экране ПК там просто нечему показывать.
Решение — самописец на самом экране: кольцевой буфер последних N значений и график, обновляющийся раз в секунду. Ни SD-карты, ни сети не нужно: 128 байт под числа — и у прибора появляется память о прошлом. Разберём оси, масштабирование и отрисовку так, чтобы график не съел ни ОЗУ, ни шину.
Самописцы такого рода у меня живут в трёх местах: график давления перед грозой в гараже, уровень CO2 в спальне и вес пчелиного улья. Во всех трёх случаях цифра видна, только когда смотришь, а график хранит всё, что было, пока ты не смотрел: на нём видна и ночь, и обеденный пик, и то самое отключение электричества, о котором я узнал слишком поздно.
Оси и масштаб: map в высоту экрана
Горизонтальная ось — время: каждая точка — один замер, соседние разнесены на 2 пикселя, 64 точки закрывают всю ширину экрана. Вертикальная — значение: map(v, vmin, vmax, 62, 2) прижимает нижнюю базу к y=62, а потолок к y=2, потому что ось y экрана растёт вниз. Базовая линия — drawLine(0, 63, 127, 63), подписи минимума и максимума — по углам через setCursor: сетка строк 6×8 с этим справляется, как в уроке про текст.
Диапазон — место, где обычно промахиваются. map(v, 0, 1023, ...) для термометра утопит сигнал в трёх нижних строках: температура живёт в 10–35 °C, а не на всём ходе АЦП. Масштабируйте под фактический диапазон своего датчика и пропустите результат через constrain перед рисованием, чтобы редкий выброс не улетел за оси. И проверьте границы: map с обращёнными краями — 62 и 2 — работает штатно, а вот vmin, равный vmax, превращает формулу в деление на ноль.
Пара нюансов из практики. Отрицательные значения — не проблема: map переваривает отрицательные края, диапазон от минус двадцати до плюс сорока даст график и мороза, и жары. Нелинейные датчики сначала пересчитывают в физику — термистор в градусы, газ в проценты, — и только потом масштабируют: рисовать сырые единицы АЦП на осях прибора я себе запрещаю, потому что через месяц никто, включая меня, не понимает, что там нарисовано.
Кольцевой буфер: память против окна истории
Кольцевой буфер — массив на N точек и указатель записи: buf[head] = v; head = (head + 1) % N. Читаете от старейшей: элемент i лежит в (head + i) % N. Перезаписывается всегда самое старое значение — сдвигать массив циклом не нужно, в этом вся магия кольца. 64 точки int — 128 байт ОЗУ; рядом с 1024-байтовым буфером дисплея на Uno каждый байт считается, мы это проходили в первом уроке раздела. Массивы и паттерны лога, если подзабылись, — в уроке про массивы.
Период выборки выбирает, что вы увидите: 64 точки раз в секунду — чуть больше минуты истории; раз в 30 секунд — полчаса; раз в 5 минут — больше пяти часов. Чаще — живее реакция, но короче окно. Моё правило: период должен быть в десять-двадцать раз больше самого быстрого процесса, который вы хотите рассмотреть, иначе график превращается в сплошной шум.
Если нужен второй график на том же экране — буфер делится пополам: 32 точки на левую половину и 32 на правую, шаг в один пиксель. А если значения гарантированно влезают в 0–255 — экономьте вдвое: массив из byte вместо int, 64 байта вместо 128. На Uno такие решения принимаются не из педантичности: буфер дисплея уже забрал свою половину ОЗУ, и каждый следующий массив ведёт переговоры с остатком.
| Период выборки | Окно истории (64 точки) | Годится для |
|---|---|---|
| 250 мс | 16 секунд | Реакция на ручку, переходные процессы |
| 1 секунда | Чуть больше минуты | Температура комнаты, ход нагрева |
| 30 секунд | 32 минуты | Ночь в теплице, остывание |
| 5 минут | Больше пяти часов | Суточный ход, работа от батареи |
Отрисовка: полилиния из drawLine
Отрисовка — полилиния: drawLine(x, yPrev, x + 2, y) между соседними точками, 63 линии на кадр. Каждая линия стоит единицы микросекунд процессора: примитивы GFX — это циклы по drawPixel, и грузит не рисование, а передача кадра — около 92 мс по I2C при 100 кГц, как в уроке про цену кадра. Поэтому кадр собирается в буфере и уходит целиком одним display() — механика разобрана в уроке про двойную буферизацию.
Обновление — раз в секунду по разности millis, без delay, чтобы опрос кнопок и датчиков не замирал: планирование без задержек здесь обязательный фон. И честное слово вместо рекламного: это самописец, а не осциллограф. Событие быстрее пары десятков миллисекунд он пропустит — выборка раз в секунду о нём просто не узнает. Для быстрых фронтов берут шаг в 1 пиксель, SPI вместо I2C и выборку десятки раз в секунду; для температуры комнаты мой формат — раз в секунду или реже.
Автодиапазон — отдельная развилка: минимум и максимум по буферу с мёртвой зоной в 3–5 единиц, иначе масштаб дёргается от шума в каждой новой точке. Для стационарного прибора я держу фиксированный диапазон — подписи осей честные и не перескакивают; автодиапазон оставляю отладке, когда диапазон датчика заранее неизвестен.
Посчитаем долю времени, которую график отнимает у шины. Кадр — около 92 мс при 100 кГц; обновление раз в секунду — это 9 процентов занятой шины, и датчикам на тех же проводах остаётся остальное. Разгон до 400 кГц ужимает кадр до 23 мс — уже 2 процента, но требует коротких проводов и живых подтяжек. А вот обновление раз в 100 мс при 100 кГц — это 92 процента: шина практически занята целиком, и любой сосед по I2C начинает захлёбываться. Числа считаются в одну строку — держите их в голове, прежде чем крутить частоту обновления.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); const int N = 64; // 64 точки с шагом 2 px — вся ширинаint buf[N]; // здесь уже масштабированные yint head = 0;long lastKadr = 0;int vmin = 10, vmax = 35; // диапазон датчика, для примера C void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); for (int i = 0; i < N; i++) buf[i] = 62; // старт: линия по базе} void loop() { long now = millis(); if (now - lastKadr < 1000) return; // кадр раз в секунду lastKadr = now; int raw = analogRead(A0); // сюда встаёт ваш датчик int y = constrain(map(raw, 0, 1023, 62, 2), 2, 62); buf[head] = y; head = (head + 1) % N; display.clearDisplay(); display.drawLine(0, 63, 127, 63, SSD1306_WHITE); // базовая линия display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.print(vmax); display.setCursor(0, 56); display.print(vmin); int xPrev = 0; int yPrev = buf[head]; // старейшая точка for (int i = 1; i < N; i++) { int j = (head + i) % N; int x = i * 2; display.drawLine(xPrev, yPrev, x, buf[j], SSD1306_WHITE); xPrev = x; yPrev = buf[j]; } display.display();}- Кольцевой буфер — массив на N точек и head: запись в head, чтение от старейшей через (head + i) % N; 64 точки int — 128 байт ОЗУ
- Вертикальный масштаб — map(v, vmin, vmax, 62, 2) с constrain: диапазон берите фактический, не весь ход АЦП
- Горизонталь — время: шаг 2 пикселя на точку; период выборки решает, окно какой длины вы увидите
- Полилиния из drawLine грузит не процессор, а шину: кадр собирается в буфере и уходит одним display() раз в секунду
- Это самописец, а не осциллограф: события быстрее пары десятков миллисекунд он пропускает; автодиапазон — только с мёртвой зоной
- 1. Где после записи buf[head] = v; head = (head + 1) % N лежит самая старая точка?
- 2. Что вернёт map(35, 10, 35, 62, 2) при верхнем значении датчика 35?
- 3. 64 точки с выборкой раз в секунду — какое окно истории?
- 4. Почему полилиния из 63 drawLine не грузит процессор?
- 5. Зачем автодиапазону мёртвая зона в 3–5 единиц?
Частые вопросы по теме урока
- Где после записи buf[head] = v; head = (head + 1) % N лежит самая старая точка?
- Запись затирает старейшую точку и сдвигает head на следующую — он и указывает на самое старое значение из живущих. По индексу head — указатель записи сместился за неё
- Что вернёт map(35, 10, 35, 62, 2) при верхнем значении датчика 35?
- Обращённый порядок границ 62 и 2 прижимает большее значение к верхней точке: чем жарче, тем выше линия на экране. 2 — верх графика
- 64 точки с выборкой раз в секунду — какое окно истории?
- Окно истории — N, умноженное на период: 64 × 1 с = 64 секунды. Раз в 30 секунд — уже полчаса истории. 64 секунды — чуть больше минуты
- Почему полилиния из 63 drawLine не грузит процессор?
- Каждая линия — цикл из единиц микросекунд процессора; кадр тормозит шина, поэтому рисуют в буфер и шлют одним display(). Каждая линия — единицы микросекунд; узкое место — передача кадра по I2C
- Зачем автодиапазону мёртвая зона в 3–5 единиц?
- Минимум и максимум по буферу скачут от шума; мёртвая зона не даёт масштабу пересчитываться из-за каждого выброса. Чтобы масштаб не дёргался от шума в каждой новой точке