Меню для регулятора полива я написал за десять минут и неделю удивлялся, почему оно живёт своей жизнью. Пункты дрожали, потому что я перерисовывал список на каждом проходе loop — так было короче. Кнопка «вниз» листала по два-три пункта за нажатие, потому что я читал состояние без фронта. А лечил я всё это delay(300) после каждой печати, отчего меню отзывалось с реакцией уставшего оператора. Три грабли за один вечер — и все три типовые.
Грабли раскладываются на две части. Первая — цена кадра: печать меню на каждом проходе — это около 92 мс занятой шины I2C, как мы считали в уроке про графику; остальная логика стоит в очереди. Экран при этом обновлялся пять раз в секунду — и три из пяти кадров были точно такими же, как предыдущий: чистая работа впустую. Вторая — у кнопки нет фронта: состояние читается, но нажатие не отличается от удержания, и дребезг контактов превращается в лишние шаги списка.
Соберём меню правильно: список пунктов в массиве, курсор-стрелка, автомат из двух состояний — просмотр и правка — и перерисовка только тогда, когда что-то изменилось. Простую версию меню на светодиодах и Serial мы уже делали в уроке про кнопочное меню — здесь та же логика, но на пиксельном экране, где каждый кадр имеет цену.
Список, курсор и сетка 21×8
При setTextSize(1) экран 128×64 — это 8 строк по 21 символу; сетку мы считали в уроке про print и setCursor. Меню живёт в ней свободно: до шести-семи пунктов плюс строка статуса внизу. Пункты — массив строк: String punkty[3]; на макетке это удобно, а на Uno каждая строка — отдельные байты ОЗУ, поэтому в серьёзном проекте тексты держат во флеше через F-макрос и PROGMEM — приём из урока про память.
Курсор — символ в начале строки: у активного пункта печатаешь префикс > и пробел, у остальных — два пробела, чтобы строки не прыгали по горизонтали. Вычурные иконки рисуются примитивами GFX, но на старте ASCII-стрелки хватает с головой. И помните: печать за край экрана молча обрезается — держите пункты до 19 символов, чтобы вместе с курсором влезать в отведённые 21. Меню с восемью пунктами влезает без прокрутки только ценой строки статуса: выбирайте, что важнее, прежде чем список разрастётся.
Строку статуса внизу я выношу всегда: там живут значения, которые меню меняет — включён ли свет, какой порог. Без неё меню — каталог без содержания: листаешь, выбираешь, а результат виден только через секунду, когда экран уже перерисовался обратно.
Если пунктов больше восьми — начинается прокрутка, и тут главное не перемудрить. Из 8 строк сетки заголовок и строка статуса забирают две, на список остаётся шесть: держу на экране окно из шести пунктов и индекс первого видимого, курсор гуляет внутри окна, а окно сдвигается, когда курсор упирается в край. Вся арифметика — пара вычитаний, но без неё список из двенадцати пунктов либо прыгает окном целиком, либо уезжает за край.
Кнопки ставлю на INPUT_PULLUP: нога к GND, отпускание — HIGH, нажатие — LOW. Команда рождается на фронте — моменте, когда новое состояние не совпало с прошлым; сравнение с last-переменной даёт ровно одно срабатывание на одно нажатие. Дребезг гасится по millis: после принятого фронта 20–30 мс новые фронты игнорируются — механика разобрана в уроке про антидребезг. Задержка delay() здесь не годится: она замораживает и экран, и опрос остальных кнопок, — живой цикл держим на millis, как в уроке про тайминг без задержек.
Третье действие — выбор — я делаю длинным нажатием: во фронте запомнил время, через 700 мс удержания сработал выбор, на отпускании — обычный шаг, если длинное не случилось. На реальной плате честнее три кнопки: D2 — вниз, D3 — вверх, D4 — выбор, как в скетче ниже. В симуляторе интерактивных входов два, поэтому раскладка редуцированная, и это честно оговорено в брифе к скетчу.
Порядок опроса простой: у каждой кнопки своя last-переменная, а три кнопки — это три вызова одной функции с фронтом. Когда кнопок много, last-переменные собираются в массив, опрос — в цикл: так же устроены и матричные клавиатуры. Единственное железное правило — ни одна кнопка не опрашивается через delay: иначе нажатие второй кнопки во время паузы просто теряется. Ниже — живая лаборатория матричной клавиатуры 4×4: сканер ведёт строки в LOW, столбцы живут на INPUT_PULLUP, нажатие — LOW на пересечении; шестнадцать клавиш на восьми пинах — вот куда вырастает массив last-переменных.
Опрос строк и столбцов: как подтяжки ловят нажатия и почему дребезг не страшен.
Подгружаем интерактив…
Автомат состояний: просмотр и правка
Теперь главное: одни и те же кнопки должны значить разное в разных режимах. В просмотре листаем пункты; в правке порога ручка меняет число, короткое нажатие сохраняет, тумблер отменяет. Это конечный автомат: состояние — переменная, поведение — switch по ней, ровно как светофор в уроке про автоматы. На двух состояниях ещё можно протиснуться if-ами и флагами; на трёх — уже плетётся, и каждое новое окно меню превращает код в гербарий. Состояния я называю словами — NAV, EDIT, а не ноликами: через полгода код читает не машина, а я сам.
Переходы я выписываю до кода: NAV переходит в EDIT выбором пункта Porog; EDIT возвращается в NAV сохранением или отменой; в EDIT листание пунктов запрещено, иначе курсор уезжает, пока экран показывает число. Звучит очевидно, но именно здесь я однажды листал список, оставаясь в правке, и полчаса не мог понять, почему порог меняется у другого пункта.
Для правки чисел у меня есть мнение, купленное опытом: если диапазон большой, ручка или энкодер быстрее кнопок. Порог от 0 до 99 кнопками «плюс-минус» — до 99 нажатий; ручкой A0 — одно движение. Кнопки остаются для малых диапазонов и переключателей, где важен точный шаг: контраст экрана на пару делений, вкл-выкл, единица градуса.
| Действие | NAV — просмотр | EDIT — правка порога |
|---|---|---|
| Короткое нажатие D2 | Шаг вниз по списку | Сохранить и вернуться |
| Щелчок тумблера D3 | Шаг вверх по списку | Отменить правку |
| Удержание D2 около секунды | Выполнить пункт | — |
| Ручка A0 | — | Меняет порог 0–99 |
Перерисовка только изменённого — приём, который спасает шину. Собираю ключ состояния: state, умноженный на 100, плюс cursor, умноженный на 10, плюс ledOn; храню lastKey и зову перерисовку, только когда ключ изменился. В правке порога экран обновляется при фактическом изменении значения, но не чаще раза в 150 мс — ручка крутится непрерывно, и без этого ограничения кадры бы посыпались. Контроль — счётчик кадров в Serial: меню в покое не должно печатать KADR ни разу; печатает — значит, где-то перерисовка зря.
Зачем столько осторожности: кадр стоит около 92 мс шины при 100 кГц, и каждая лишняя перерисовка тормозит всё, что живёт на тех же A4/A5 — часы, датчики, второй экран. Двойная буферизация, которую мы разбирали в уроке про clearDisplay и display, не спасает от пустой работы: буфер очищается быстро, а вот передача кадра — нет.
Тот же принцип расстилается дальше меню. Значения датчиков я рисую по таймеру — раз в секунду, независимо от того, как часто они читаются; мигалки и курсоры — по своему расписанию; а шапку, которая не меняется, трогать не должен никто. Экран, обновляющийся по делу, ещё и выглядит спокойнее: без дрожания цифр, о котором я писал в уроке про частоту кадров.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); enum State { NAV, EDIT }; // автомат: просмотр / правкаState state = NAV; String punkty[3] = {"Svet", "Signal", "Porog"};int kursor = 0;bool ledOn = false;int porog = 50; // правится ручкой A0 в EDIT int lastDn = HIGH, lastUp = HIGH, lastOk = HIGH;long lastPress = 0;long lastKadr = 0; // фронт с гашением дребезга: после принятого фронта 30 мс тишиныbool klik(int pin, int &last) { int now = digitalRead(pin); bool res = false; if (last == HIGH && now == LOW && millis() - lastPress > 30) { res = true; lastPress = millis(); } last = now; return res;} void risujMenu() { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.println("MENYU"); for (int i = 0; i < 3; i++) { display.setCursor(0, 12 + i * 12); if (i == kursor) display.print("> "); else display.print(" "); display.println(punkty[i]); } display.setCursor(0, 52); display.print("Svet: "); display.println(ledOn ? "VKL" : "VYKL"); display.display();} void risujEdit() { display.clearDisplay(); display.setTextSize(1); display.setCursor(0, 0); display.println("Porog EDIT"); display.setTextSize(2); display.setCursor(0, 24); display.print(porog); display.println(" %"); display.setTextSize(1); display.setCursor(0, 56); display.println("D2 sohranit D3 otmena"); display.display();} void vypolnit() { // выбор пункта if (kursor == 0) { ledOn = !ledOn; digitalWrite(12, ledOn); risujMenu(); } if (kursor == 1) { tone(8, 1200, 200); } if (kursor == 2) { state = EDIT; risujEdit(); }} void setup() { pinMode(2, INPUT_PULLUP); // вниз pinMode(3, INPUT_PULLUP); // вверх pinMode(4, INPUT_PULLUP); // выбор pinMode(12, OUTPUT); display.begin(SSD1306_SWITCHCAPVCC, 0x3C); risujMenu();} void loop() { if (state == NAV) { if (klik(2, lastDn)) { kursor = (kursor + 1) % 3; risujMenu(); } if (klik(3, lastUp)) { kursor = (kursor + 2) % 3; risujMenu(); } if (klik(4, lastOk)) vypolnit(); } else { porog = map(analogRead(A0), 0, 1023, 0, 99); if (millis() - lastKadr > 150) { lastKadr = millis(); risujEdit(); } if (klik(2, lastDn)) { state = NAV; risujMenu(); } // сохранить if (klik(3, lastUp)) { porog = 50; state = NAV; risujMenu(); } // отмена }}- Сетка size 1 — 8 строк по 21 символу: до 6–7 пунктов меню плюс строка статуса; пункты — массив строк, курсор — префикс >
- Команда рождается на фронте: сравнение с прошлым состоянием плюс 20–30 мс тишины по millis вместо delay
- NAV и EDIT — состояния автомата: одни и те же кнопки значат разное, switch по состоянию вместо гирлянды if
- Перерисовка по ключу состояния: экран обновляется, только когда изменились state, курсор или данные; счётчик KADR в Serial — контроль пустой работы
- Длинное нажатие — третий вход в двухкнопочной раскладке: время фронта запомнил, 700 мс прошло — это выбор
- 1. Почему печать меню на каждом проходе loop — плохая идея?
- 2. Что даёт сравнение кнопки с прошлым состоянием (фронт)?
- 3. Зачем меню два состояния — NAV и EDIT?
- 4. Сколько строк и символов в строке даёт шрифт size 1 на 128×64?
- 5. Как проверить, что меню не перерисовывается зря?
Частые вопросы по теме урока
- Почему печать меню на каждом проходе loop — плохая идея?
- Кадр — 1024 байта по шине; гонять его без изменений значит отбирать время у датчиков и кнопок на тех же A4/A5. Каждый кадр занимает шину I2C около 92 мс при 100 кГц — остальная логика стоит в очереди
- Что даёт сравнение кнопки с прошлым состоянием (фронт)?
- Фронт отличает новое нажатие от удержания, но дребезг давит отдельно — 20–30 мс тишины по millis после принятого фронта. Одно срабатывание на одно нажатие, а не на каждый проход loop при удержании
- Зачем меню два состояния — NAV и EDIT?
- Автомат разделяет поведение: в NAV кнопки листают список, в EDIT правят значение — без этого кнопки путаются между режимами. Одни и те же кнопки значат разное в разных режимах
- Сколько строк и символов в строке даёт шрифт size 1 на 128×64?
- 128/6 = 21 символ, 64/8 = 8 строк. С курсором в начале строки на пункт остаётся 19 символов. 8 строк по 21 символу
- Как проверить, что меню не перерисовывается зря?
- Счётчик, растущий при пустом экране, сразу показывает лишние кадры: каждая перерисовка — около 92 мс занятой шины. Счётчик кадров KADR в Serial: в покое он не должен расти