Уровнемер воды в баке на даче я собрал за один вечер и почти месяц потом переделывал из-за одной цифры. Первая версия честно печатала процент крупным шрифтом: 74, 75, 76. Работало, но экран висит на стенке сарая, смотрят на него издалека и под углом — цифру ещё подойди прочти. Отец, главный пользователь, сформулировал задачу по-своему: сделай полоску, как на телефоне, чтобы краем глаза, из другой комнаты, было видно, сколько осталось.
Полоску я нарисовал за десять минут и тут же испортил. Рамку ставил drawRect шириной 40 пикселей, заливку — fillRect той же ширины. На полном баке белое поле прижималось к правой стенке рамки, а на 100 процентах и вовсе съедало её: где кончается шкала и начинается заливка, разобрать было нельзя, бар выглядел заклеенным. Лечение оказалось скучным до зевоты: внутренняя ширина шкалы — на два пикселя меньше внешней, по пикселю на каждую стенку, и все проценты считаются от неё, а не от ширины рамки.
Здесь собираем приборные элементы по косточкам: рамка и заливка, проценты из аналогового входа, вертикальный столбик, слайдер с ручкой. Примитивы мы уже трогали в уроке про графику, конвейер кадра и темп обновления — в уроке про буфер и display(); и то и другое здесь крутится на каждом обновлении экрана. К концу урока у вас будет экран прибора: рамка с заливкой, число, слайдер — всё из пары примитивов и одной формулы.
Рамка и заливка: что под капотом прогресс-бара
Прогресс-бар — это два прямоугольника, ничего больше. drawRect(x, y, 40, 10, SSD1306_WHITE) рисует рамку: контур в один пиксель, внутри пусто. fillRect(x + 1, y + 1, fill, 8, SSD1306_WHITE) закрашивает заполненную часть: с отступом по пикселю от каждой стенки, высотой на два меньше рамки. Отсюда моё правило после съеденной стенки: внутренняя ширина всегда w - 2, для шкалы на 40 пикселей — 38, и fill считается от неё.
Заполнение в пикселях: fill = procent * 38 / 100. При 74 процентах это 28 пикселей белого; при 100 — все 38, вплотную к стенкам, но стены целы; при нуле fillRect с шириной ноль я просто не зову. Проценты перед расчётом прижимаю constrain(procent, 0, 100): датчик, который выдаст 104 или минус три, иначе протащит заливку за края рамки — и стенку, которую мы берегли, съест уже мой собственный fillRect.
Число процентов я печатаю в шапке рядом с именем прибора: полоска отвечает на вопрос «много ли», число — на «ровно сколько», и спорят они редко. Приём тот же, что в уроке про текст и setCursor: имя слева, процент правее, курсор ставлю явно перед каждой печатью, потому что print без него дописывает к чужому слову.
И про цену: бар не удорожает кадр ни на байт. Оба прямоугольника — десятки микросекунд процессора и ноль дополнительной памяти, вся передача по-прежнему упирается в 1024 байта кадра — те самые 92 миллисекунды по I2C на 100 кГц, которые мы считали в уроке про буфер. Дешевизна и делает такие элементы основой дашбордов: их можно насыпать сколько угодно, кадр от этого не толстеет.
Последняя привычка экономная: обновляйте бар только при изменении. Уровень воды в баке ползёт раз в минуты — держите прошлый fill и зовите display() лишь когда заливка реально сдвинулась на пиксель: шина отдыхает, батарейный узел живёт дольше, а на экране ничего не меняется, ведь кадр контроллер держит сам. Для громкости или ползунка в руке правило не годится — там обновляемся по расписанию всегда.
| Элемент | Из чего собирается | Где я его ставлю |
|---|---|---|
| Горизонтальный бар | drawRect рамка + fillRect заливка, fill = p * (w - 2) / 100 | заряд, уровень воды, загрузка сети |
| Вертикальный столбик | тот же дуэт, заливка от низа: y + (h - 2) - fill | сигнал антенны, громкость |
| Слайдер с ручкой | трек fillRect + ручка fillCircle в позиции значения | настройка порога прямо на приборе |
| Число процентов | setCursor правее имени + print значения | шапка дашборда, подпись у бара |
Вертикальный столбик — тот же дуэт, повёрнутый набок, и считать его положено от низа, как уровень воды: fillRect(x, y + (h - 2) - fill, w - 2, fill). Верхний край заливки ползёт вверх с ростом fill, дно прижато к нижней стенке рамки. Я собираю так индикатор сигнала: десять столбиков шириной 8 пикселей с зазором в 4, у каждого своя заливка — код от горизонтального бара отличается только парой координат.
Сырьё для процентов чаще всего приходит с аналогового входа: map(analogRead(A0), 0, 1023, 0, 100). Как потенциометр отдаёт 0–1023 и почему без подтяжки вход висит в воздухе — разобрано в уроке про analogRead. Полный ход ручки у типового потенциометра на 10 кОм даёт все 1024 отсчёта, и map переводит их в 0–100 без остатка; на краях хода сотые доли процента теряются — для полоски это неважно. Сам map — тот же приём, что в диммере из раздела основ, с одной разницей: на выходе не яркость светодиода, а ширина заливки в пикселях.
Тонкое место — дрожь младшего разряда. АЦП Uno плавает на одну-две единицы даже с неподвижной ручкой, и на границе процента полоска мигает туда-сюда, будто вода плещется. Моё лечение двустороннее: бегущее среднее gl = (gl * 3 + fresh) / 4 и обновление экрана раз в 200–250 мс по millis — схема из урока про millis вместо delay. При неподвижной ручке бар стоит как вкопанный, при повороте догоняет за три-четыре обновления, то есть за полсекунды.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1);long lastDraw = 0;int gl = 0; // сглаженный процент void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); Serial.begin(9600);} void loop() { if (millis() - lastDraw >= 200) { lastDraw = millis(); int fresh = map(analogRead(A0), 0, 1023, 0, 100); gl = (gl * 3 + fresh) / 4; // бегущее среднее против дрожи gl = constrain(gl, 0, 100); display.clearDisplay(); display.setTextSize(1); display.setCursor(0, 0); display.print("BAK "); display.print(gl); display.println(" %"); // шкала: рамка 40x10, внутренняя зона 38x8 display.drawRect(0, 16, 40, 10, SSD1306_WHITE); int fill = gl * 38 / 100; // умножение до деления if (fill > 0) { display.fillRect(1, 17, fill, 8, SSD1306_WHITE); } // слайдер: трек через весь экран и ручка-диск display.fillRect(0, 40, 128, 3, SSD1306_WHITE); int xh = 4 + (120 * gl) / 100; // 4..124, за край не вылезет display.fillCircle(xh, 41, 4, SSD1306_WHITE); display.display(); Serial.print("proc="); Serial.println(gl); }}Слайдер: трек и ручка
Слайдер собирается из тех же фигур плюс ручка. Трек — fillRect высотой 2–3 пикселя во всю ширину: линейка, по которой видно диапазон. Ручка — fillCircle(xh, y, 4): диск радиусом 4 пикселя в точке текущего значения. Позиция считается из тех же процентов: xh = 4 + (120 * gl) / 100 — при нуле диск прижат к левому краю, при сотне к правому, и в промежутке не задевает делений.
Радиус ручки я подбираю с условием: для шкалы на весь экран беру 3–4 пикселя. Меньше — ручка тонет в треке и теряется на солнце; больше — на линейке в 128 пикселей закрывает соседние деления и выглядит заглушкой. Для маленькой шкалы на 60 пикселей — радиус 3 и трек потоньше, иначе пропорции плывут.
На статичных порогах добавьте зону статуса под слайдером: выше восьмидесяти — HIGH, ниже двадцати — LOW, между ними — NORM, и переключайте её с гистерезисом в два процента. Без гистерезиса на границе статус мигает туда-сюда при каждой единице дрожи АЦП — та же болезнь, что у бара, и лечится тем же бегущим средним: сравнивать с порогом нужно уже сглаженное значение, а не свежий отсчёт.
Самый полезный слайдер в моих приборах — настройка порога. Крутишь ручку потенциометра, на экране ползунок и число, кнопкой подтверждаешь — полный цикл настройки устройства без компьютера. Основу мы делали в уроке про диммер на map, здесь к ней добавляется наглядность: пользователь видит, где он стоит на шкале, а не угадывает по метанию цифры.
И мостик дальше: рядом с баром хочется не только геометрии, но и настоящей картинки — капли у уровня воды, градусника у температуры. Картинки на SSD1306 живут в массивах байтов, и это тема следующего урока про drawBitmap.
- Прогресс-бар — два вызова: drawRect рамка и fillRect заливка с отступом по пикселю от каждой стенки
- Внутренняя ширина шкалы — w - 2; пиксели заливки: fill = p * (w - 2) / 100, умножение до деления
- Вертикальный столбик считают от низа: fillRect(x, y + (h - 2) - fill, w - 2, fill)
- Проценты из потенциометра: map(analogRead(A0), 0, 1023, 0, 100), сверху constrain(0, 100)
- Дрожь АЦП лечится бегущим средним и обновлением раз в 200–250 мс по millis; ручка слайдера — fillCircle в позиции значения
- 1. Какие два вызова собирают прогресс-бар?
- 2. Рамка шириной 50 пикселей, заливка на 100 процентах. Сколько пикселей займёт заливка?
- 3. Число процентов меняется, а заливка стоит на нуле при любом значении. Что проверить первым?
- 4. Откуда удобнее брать проценты для бара-уровня с потенциометром?
- 5. Ручка слайдера дрожит на месте без поворота. Что поможет?
Соберите экран уровня в симуляторе: шапка, число, полоска из клеток — и крутите ручку. Симулятор честен только для текстового слоя: заливки и круги он не рисует, их оставляем реальному OLED. Но проценты, map, бегущее среднее и темп обновления в симе и на плате совпадают до байта — логика переносится без правок. Полоска в симуляторе набрана из двадцати клеток — это те же внутренние пиксели шкалы, только в символьной сетке.
Домашнее задание: экран «вода в баке» из трёх зон — шапка с процентами, горизонтальный бар на 80 пикселей и вертикальный столбик справа на 10. Посчитайте на бумаге внутренние ширины обеих шкал и прогоните формулу заливки на граничных значениях: 0, 1, 50, 99 и 100 процентах. Границы — то место, где ломаются стенки: именно на 99-м проценте моя первая версия и протекла.
И приём напоследок: если датчик замолчал, бар всё равно честен — заливка стоит на последнем значении, потому что кадр живёт на контроллере. Подвальная строка с возрастом данных из урока про текст здесь спасает: пользователь видит, что вода считалась час назад, а не сейчас.
Частые вопросы по теме урока
- Какие два вызова собирают прогресс-бар?
- Рамка рисуется контурным прямоугольником, заполненная часть — залитым с отступом по пикселю от стенок. Никаких новых функций для бара не нужно. drawRect рамка и fillRect заливка внутри неё
- Рамка шириной 50 пикселей, заливка на 100 процентах. Сколько пикселей займёт заливка?
- Внутренняя ширина шкалы — w - 2: по пикселю на каждую стенку, иначе заливка вплотную ложится к рамке и на границе съедает её. 48 — по пикселю с каждой стороны
- Число процентов меняется, а заливка стоит на нуле при любом значении. Что проверить первым?
- Запись (p / 100) * w на целых числах даёт ноль при p меньше ста: целочисленное деление обнуляет проценты до умножения. Порядок операций: умножение до деления
- Откуда удобнее брать проценты для бара-уровня с потенциометром?
- Аналоговый вход отдаёт 0–1023, map переводит его в 0–100. Сверху полезен constrain — датчик может выдать и больше сотни. map(analogRead(A0), 0, 1023, 0, 100)
- Ручка слайдера дрожит на месте без поворота. Что поможет?
- Дрожь младшего разряда АЦП лечится усреднением и нечастым обновлением. delay вместо millis заморозит весь цикл — кнопки и датчики перестанут опрашиваться. Бегущее среднее и обновление раз в 200–250 мс