Дашборд теплицы я собрал за вечер и испортил за ночь. Версия первая перерисовывала экран на каждом проходе loop: clearDisplay, печать, display() — и всё это без единой паузы. К утру выяснились две вещи: цифры дрожали, как в дешёвом будильнике, а кнопка режима, висевшая на той же шине I2C, срабатывала через раз. Экран, честный по-своему, забирал шину почти целиком.
Диагноз оказался скучным: я не посчитал цену кадра. 1024 байта по шине — не мгновение, и отправлять их каждую миллисекунду — значит отбирать время у всех остальных устройств. Вторая версия с обновлением по millis() раз в 100 мс заработала так, как я изначально рисовал в голове: плавно, без дрожи, и кнопка ожила.
В этом уроке разложим пиксельную сетку и примитивы Adafruit GFX — drawLine, drawRect, drawCircle, — посчитаем, сколько длится передача кадра по I2C и какая частота обновления разумна. В симуляторе соберём мини-интерфейс: дашборд температуры и времени с обновлением раз в 100 мс; графические примитивы оставим настоящей плате — механика буфера у них общая.
Симулятор в этом уроке честный союзник: текстовый API он эмулирует по-настоящему, включая таймер на millis(), так что темп обновления и «дрожание» частых кадров вы почувствуете на нём, а примитивы отрепетируете на плате. Порядок такой: сначала зоны и частота кадров в симуляторе, потом рамка и линии на реальном экране.
Пиксельная сетка и примитивы Adafruit GFX
Экран — сетка 128×64 точек. Начало координат — левый верхний угол, точка (0, 0); ось x растёт вправо до 127, ось y — вниз до 63. Отличие от школьной геометрии налицо: там y растёт вверх. С OLED и TFT эта перевёрнутая система встретится везде — привыкайте к ней сразу, чтобы не переворачивать координаты потом.
Поверх сетки Adafruit GFX даёт примитивы. drawPixel(x, y, SSD1306_WHITE) зажигает одну точку. drawLine(x0, y0, x1, y1, цвет) — линия из точки в точку. drawRect(x, y, ширина, высота, цвет) — контур прямоугольника, fillRect — залитый. drawCircle(x, y, радиус, цвет) — окружность, fillCircle — диск. Порядок аргументов везде один: сначала точка привязки, потом размер. Цвета два: SSD1306_WHITE включает пиксели, SSD1306_BLACK гасит — рисование чёрным работает ластиком и спасает, когда надо стереть прошлую цифру, не трогая остальной кадр.
Выучивать все примитивы наизусть не надо. Рабочий минимум, который я использую в девяти интерфейсах из десяти: drawLine для разделителей, fillRect для «плашки» под инверсный заголовок и drawCircle как индикатор состояния — залитый значит «всё хорошо», контурный значит «внимание». drawRoundRect со скруглёнными углами добавляет рамке благородства, drawTriangle остаётся для стрелок. Остальные живут в шпаргалке к Adafruit GFX, а важно понимать другое: любой примитив — это цикл из drawPixel, стоит он одинаково — несколько микросекунд процессора и ноль лишней памяти.
Оговорка про симулятор: он эмулирует текстовую часть API — clearDisplay, setTextSize, setCursor, print, display. Примитивы в симуляторе не рисуются — их оставим реальной плате, а механику проверим на том, что у обоих миров общего до байта: всё рисуется в буфер в ОЗУ, display() отправляет кадр целиком. Запомните конвейер — и на плате примитивы встанут в него без единого изменения логики.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.clearDisplay(); display.drawRect(0, 0, 128, 64, SSD1306_WHITE); // рамка по периметру display.drawLine(0, 10, 127, 10, SSD1306_WHITE); // разделитель под шапкой display.fillCircle(12, 32, 6, SSD1306_WHITE); // индикатор-«лампочка» display.display();} void loop() {}Цена кадра: почему экран мерцает
В мерцании почти никогда не виноват буфер: display() — одна цельная передача, без полупередач. Виновата частота. Считаем: кадр — 1024 байта, на каждый байт по I2C уходит 9 бит — восемь данных плюс бит подтверждения. При стандартных 100 кГц кадр занимает около 92 мс: 1024 × 9 / 100 000. Обновление каждые 50 мс означает, что шина занята почти всё время — кнопки и датчики на том же I2C ждут очереди, а глаза видят дрожание соседних кадров. Разгон шины до 400 кГц сокращает кадр до ~23 мс, но требует и живых подтяжек, и коротких проводов — на макетке это лотерея.
Лечение — режим, а не хитрая оптимизация: обновляйте экран 4–10 раз в секунду, то есть раз в 100–250 мс, а между обновлениями только пересчитывайте данные. И держите цикл живым: без delay, по разности millis() — как в уроке про тайминг. Моё правило с условием: 5–10 кадров в секунду для любых цифр и текста я считаю достаточными всегда; быстрее — только если на экране настоящая анимация, и тогда сначала проверяю, что ещё живёт на этой шине.
Прикинем потолок: если кадр идёт 92 мс, то десять кадров в секунду — это уже 920 мс из тысячи, шина занята почти целиком, другим устройствам остаётся крошка. Поэтому на Uno я живу в режиме 5–7 кадров в секунду: цифры меняются плавно, шина дышит; на ESP32, где шину можно разогнать до 400 кГц, потолок поднимается сам собой. Столкнуться с потолком можно за один вечер: добавьте в loop Serial.print на каждый кадр — и увидите, как печать в консоль и рисование на экран начинают делить один и тот же тайминг.
Отдельно про delay(100) вместо проверки millis(): это выглядит как то же самое, но работает иначе. delay замирает на весь интервал — Serial-команды не читаются, кнопка не опрашивается, таймеры тоже сидят и ждут. Схема с lastUpdate и разностью millis() стоит две строки и ничего не блокирует; я использую её по умолчанию для всех периодических задач, не только для экрана — и вам советую закрепить именно её на этом дашборде.
Мини-интерфейс: зоны, шрифты, десятые доли
Читаемый экран — это зоны, продуманные до кода. Шапка мелким шрифтом: имя прибора и статус. Разделитель — на плате drawLine, в текстовом варианте строка дефисов. Данные крупно: setTextSize(2) даёт 10 символов и 4 строки — как раз на температуру и время. Внизу мелкая строка для единиц измерения и подсказок. Интерфейс, нарисованный «по наитию», переделывать вдвое дольше, чем спланированный по зонам.
Десятые доли температуры печатаются целочисленным трюком, без дробей: храним значение умноженным на десять — 214 это 21.4; целая часть — v / 10, десятые — остаток v % 10. Так на экран не попадает float со своим «21.40» и лишним хвостом, а точности в одну десятую градуса для теплицы за глаза.
В симуляторе значения идут из массива по кругу — так удобно гонять тестовые сценарии, пока датчик не подключён; в реальном скетче вместо индекса массива будет свежее чтение DHT. Обновление — по millis() раз в 100 мс, как договорились: цикл остаётся живым, кнопки опрашиваются, кадр уходит по расписанию.
Про зоны — один практический приём: правая часть шапки — лучшее место для статуса. Имя прибора слева, состояние справа: ON, TIMEOUT, SYNC. Печатается статус тем же size(1) с явным setCursor на правую колонку: при шрифте 6×8 четыре символа занимают 24 пикселя, значит x = 104 ставит статус вплотную к правому краю. Такой уголок экономит отдельную строку и делает экран похожим на настоящий прибор, а не на лог, выведенный на стекло.
- Темп. Смените 100 мс на 250 и на 1000: сравните плавность счётчика и решите, где ваша граница между экономией шины и комфортом глаз.
- Третья зона. Добавьте влажность: массив hum10 со значениями вида 583 (58.3 %) и печать через v / 10, точка, v % 10 — подвиньте Uptime, чтобы зоны не столкнулись.
- Индикатор жизни. В нижней строке печатайте ON или OFF по чётности секунд: if (sec % 2 == 0) — мигающий признак того, что скетч жив.
- На настоящей плате. Обнесите дашборд рамкой drawRect(0, 0, 128, 64, ...) и проведите линию под шапкой drawLine — интерфейс сразу станет опрятнее.
- Начало координат — левый верхний угол (0, 0); x вправо до 127, y вниз до 63
- Примитивы Adafruit GFX: drawPixel, drawLine, drawRect, fillRect, drawCircle, fillCircle; SSD1306_BLACK гасит пиксели и работает ластиком
- Кадр — 1024 байта × 9 бит ≈ 92 мс по I2C на 100 кГц: обновляйте экран 4–10 раз в секунду
- Обновление по millis() без delay — цикл остаётся живым для кнопок и датчиков на той же шине
- Интерфейс — зоны: шапка size(1), данные size(2), подвал size(1); зоны продумывайте до кода
- Десятые доли — целочисленным трюком: значение × 10, целая часть v / 10, десятые v % 10
- 1. Где находится точка (0, 0) на экране SSD1306?
- 2. Что сделает цвет SSD1306_BLACK в аргументе примитива?
- 3. Сколько примерно идёт кадр из 1024 байт по I2C на 100 кГц?
- 4. Почему дашборд обновляется по millis(), а не delay(100)?
У вас на руках прибор с настоящим интерфейсом: зоны, крупная цифра, предсказуемый темп обновления. Дальше по разделу — меню на кнопках, свои шрифты с кириллицей и график истории температуры: 1024 байта буфера — это 8192 точки, на суточный график хватает с запасом. До встречи — добавьте в дашборд третий показатель со своего датчика, влажность или напряжение батареи: интерфейс, собранный руками, на улице читается лучше любого Serial-монитора.
И задел на будущее: целочисленный трюк с ×10 работает и для отрицательных температур — минус живёт в целой части, и печать v / 10 даст -3 при значении -34. Проверьте в симуляторе, заменив массив на зимние значения вроде -34, -31, -28, — дашборд, который честно показывает минус, готов к даче в феврале.
Частые вопросы по теме урока
- Где находится точка (0, 0) на экране SSD1306?
- Начало координат — левый верхний угол; ось y растёт вниз, в отличие от школьной геометрии. В левом верхнем углу
- Что сделает цвет SSD1306_BLACK в аргументе примитива?
- SSD1306_BLACK выключает пиксели: рисование чёрным по белому стирает фигуры, не трогая остальной кадр. Погасит пиксели — сработает как ластик
- Сколько примерно идёт кадр из 1024 байт по I2C на 100 кГц?
- 1024 байта × 9 бит / 100 000 бит/с ≈ 92 мс; на разгоне до 400 кГц — примерно 23 мс. Около 92 мс
- Почему дашборд обновляется по millis(), а не delay(100)?
- delay останавливает программу целиком; схема с millis() держит loop живым, и остальные устройства на шине не голодают. delay блокирует весь цикл — кнопки и датчики не опрашиваются