Прибор живёт на стене гаража, экран читается с двух шагов, а весь интерфейс — три кнопки под большим пальцем: вверх, вниз, выбор. Первое меню я собрал за вечер, но без выбора оно оставалось просто прокручиваемым списком: курсор бегает, а прибор не делает ничего. Разница между списком и интерфейсом именно в нажатии выбора — в момент, когда пункт превращается в действие: стартовал вентилятор, сменилась уставка, погасла подсветка. Навигацию по строкам мы уже гоняли в уроке про меню кнопками, там был только курсор; сегодня достраиваю каркас до конца: три кнопки, стрелка на строке и действие с обратной связью на экране и в Serial.
Железо минимальное: SSD1306 128×64 на I2C и три тактовые кнопки на D2, D3 и D4. Резисторы не нужны: pinMode с INPUT_PULLUP поджимает каждую линию к питанию, в покое digitalRead видит HIGH, а нажатие прижимает пин к земле — LOW. Та же подтяжка спасает от наводок: висящий в воздухе вход ловит всё подряд, и курсор ползает сам по себе, а подтянутая линия держится ровно до касания. Схему подключения самого экрана повторять не буду — она разобрана в уроке про шину I2C, там же адрес 0x3C и сканер шины на случай молчания.
Порядок такой: сначала раскладка кадра — где живёт стрелка и почему пункты начинаются с двенадцатого пикселя, — потом полный скетч с опросом кнопок без единого delay, и в конце выбор пункта с обратной связью. В самом низу — симулятор, где можно покликать все три кнопки и посмотреть лог в мониторе.
Кадр меню: стрелка, отступ, зажимы
Шрифт размера 1 занимает ячейку 6×8: на 128 пикселей выходит 21 символ в строке, на 64 пикселя — ровно восемь строк. Меню из трёх пунктов занимает верхние три строки: с y = 0, 8 и 16; нижняя строка 56 остаётся под статус — что выбрано сейчас и что прибор в ответ сделал. Стрелка «>» стоит в нулевой колонке, пункты печатаются с x = 12: между меткой и текстом зазор в две ячейки, и глаз цепляет колонку стрелок, даже не читая строки. Дальше можно пойти до заливки строки через fillRect с перекраской текста, но для трёх пунктов честной стрелки хватает: читаемость важнее красоты, а инверсная строка на SSD1306 без fillRect всё равно не получится.
Курсору нужны зажимы, и тут два пути: жёсткие границы — kurzor не выходит за 0 и NUM-1, — либо циклический проход, когда после последнего пункта стрелка прыгает на первый. Для трёх строк я ставлю жёсткие: список короткий, цикл только путает — ладонь ждёт ещё один щелчок, а его нет. На длинных меню из восьми-десяти пунктов всё наоборот: цикл честнее, крутить обратно утомительно. И мелочь, о которую спотыкаются все: пункты печатаются командой print с setCursor на каждую строку — механика курсора подробно разобрана в уроке про print и setCursor, здесь она наконец работает на нас, а не против.
Кадр собирается в буфере целиком и уезжает на стекло одним display() — базовое правило экранов Adafruit, про двойную буферизацию и цену clearDisplay писано в отдельном уроке. Для меню это значит: рисуем всё за один проход — стрелку, строки, статус, — и никогда не подрисовываем курсор поверх старого кадра. Через десять нажатий экран иначе зарастает призраками стрелок на прежних местах: буфер хранит всё, что в него напечатали.
| Пин | Кнопка | Действие в меню | Что читает digitalRead |
|---|---|---|---|
| D2 | Вверх | курсор на строку выше, зажим на нуле | HIGH в покое, LOW при нажатии |
| D3 | Вниз | курсор на строку ниже, зажим на последнем пункте | HIGH в покое, LOW при нажатии |
| D4 | Выбор | выполняет пункт, строка уходит в статус и Serial | HIGH в покое, LOW при нажатии |
Пара слов про сами кнопки. На макетке я сажаю их в ряд под экраном: большим пальцем до всех трёх дотягиваюсь, не перекладывая прибор. Провода — по две жилы на кнопку: сигнальная на пин, земля на общую шину GND. Перепутать их трудно, а вот посадить кнопку мимо ряда макетки — легко, и это классическая сцена «меню само листается»: пин без подтяжки и без кнопки ловит наводки, digitalRead скачет, курсор живёт своей жизнью. INPUT_PULLUP в скетче закрывает вопрос, но только если pinMode вызван до первого опроса — в setup, а не где-то по ходу loop.
И про темп. Человек щёлкает кнопку раз в двести-триста миллисекунд, когда листает задумчиво, и до пяти щелчков в секунду, когда торопится. Окно 40 мс с запасом перекрывает оба темпа: ни один честный щелчок не потерян, а звон контактов длиннее окна почти не бывает. Если когда-нибудь встретите кнопку, которая звенит дольше сорока миллисекунд, — меняйте кнопку: не окно должно её спасать, она своё отжила.
Adafruit_SSD1306 display(128, 64, &Wire, -1); const int BTN_UP = 2;const int BTN_DN = 3;const int BTN_OK = 4; String punkty[3];int kurzor = 0;int vybor = -1; // индекс последнего выбранного, -1 = выбора ещё не было int lastUp = HIGH;int lastDn = HIGH;int lastOk = HIGH;unsigned long tUp = 0;unsigned long tDn = 0;unsigned long tOk = 0; void risujMenu() { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); for (int i = 0; i < 3; i++) { display.setCursor(12, i * 8); display.print(punkty[i]); if (i == kurzor) { display.setCursor(0, i * 8); display.print(">"); } } display.setCursor(0, 56); display.print("Status: "); if (vybor < 0) { display.print("vybora net"); } else { display.print(punkty[vybor]); } display.display();} void setup() { Serial.begin(9600); pinMode(BTN_UP, INPUT_PULLUP); pinMode(BTN_DN, INPUT_PULLUP); pinMode(BTN_OK, INPUT_PULLUP); punkty[0] = "Start ventilyatora"; punkty[1] = "Podsvetka: vikl"; punkty[2] = "Sbros nastroyek"; risujMenu(); Serial.println("menyu gotovo: 3 punkta");} void loop() { int up = digitalRead(BTN_UP); if (up != lastUp && millis() - tUp > 40) { tUp = millis(); lastUp = up; if (up == LOW) { if (kurzor > 0) { kurzor--; } risujMenu(); Serial.print("kurzor="); Serial.println(kurzor); } } int dn = digitalRead(BTN_DN); if (dn != lastDn && millis() - tDn > 40) { tDn = millis(); lastDn = dn; if (dn == LOW) { if (kurzor < 2) { kurzor++; } risujMenu(); Serial.print("kurzor="); Serial.println(kurzor); } } int ok = digitalRead(BTN_OK); if (ok != lastOk && millis() - tOk > 40) { tOk = millis(); lastOk = ok; if (ok == LOW) { vybor = kurzor; risujMenu(); Serial.print("vybor="); Serial.println(vybor); } } delay(5);}Обратите внимание на форму опроса. Каждая кнопка ловится по фронту: читаем пин, сравниваем с прошлым значением, и только если оно сменилось и прошло больше 40 мс с предыдущей смены — обрабатываем. Окно в 40 мс глотает дребезг контактов: механика внутри тактовой кнопки после касания звенит пять-десять миллисекунд, и без окна один щелчок превращается в два-три шага курсора. Скелет цикла — тот же, что в принципе millis вместо delay: loop крутится без пауз, экран перерисовывается по событию, а не по расписанию.
delay(5) в конце loop здесь — щадящая уступка: без неё плата и симулятор крутят холостые обороты, а реакциям меню пауза в пять миллисекунд не мешает. Но как только в приборе появится сенсор или связь, уступку придётся убрать и перевести всё на millis: интерфейс с кнопками на delay(100) начинает пропускать нажатия, и вместо поиска сбоя в коде вы ловите жалобу, что кнопка иногда молчит. Правило у меня простое: меню отвечает на каждое нажатие — либо действием, либо честным зажимом на границе.
Про монтаж на настоящей макетке, раз кнопок у нас три: каждый пин — по кнопке на GND, без резисторов и без конденсаторов на старте. Конденсатор 100 нФ вдоль кнопки ставят, когда скетч уже отлажен, а окно 40 мс не помогло: у дешёвых кнопок звон доходит до 20 мс. Провода длиннее полуметра на кнопках ведут себя скверно — ловят наводки; экран на I2C я тяну сколько нужно, а кнопки держу короткими хвостами от гребёнки.
Выбор пункта: действие и обратная связь
В симуляторе кнопки стоят на D2, D3 и D4 — тем же пинам, что и в скетче. Пройдите все три пункта и посчитайте строки в мониторе: каждая пара kurzor= и vybor= должна выходить по одному разу на каждое нажатие. Это и есть признак здорового опроса — прибор отвечает на каждое касание и ни разу не реагирует дважды. На настоящей плате скетч работает без правок: те же подтяжки, то же окно, тот же порядок кадров.
Рост каркаса заложен в массиве. punkty объявлен как String punkty[3]; четвёртый пункт добавляется правкой размера, границы зажима и одной строки присваивания — стрелка, статус и опрос кнопок остаются нетронутыми. Когда пунктов станет больше восьми, сплошной список перестанет влезать в высоту 64: придётся учить каркас прокрутке окна, и вот тут выручит протокол отладки из урока про Serial-отладку логики — без печатей в монитор перепутанный индекс почти не находится.
Отдельно про нижнюю строку статуса. Она кажется украшением, но это единственное место, где прибор рассказывает, что он сделал: выбор без обратной связи — нажатие в пустоту, и через день вы уже не уверены, сработало оно или нет. Я вписываю в статус ровно одно событие — последний выбор, потому что строка на 21 символ не резиновая. Дальше по серии меню пересядет на энкодер, а после обрастёт подэкранами, и статус превратится в подсказку «куда жать дальше». Скелет при этом не изменится ни разу: массив пунктов, фронт с окном, одна перерисовка на событие.
- 1. Почему пункты меню печатаются с x = 12, а стрелка — с x = 0?
- 2. Что увидит digitalRead на пине с INPUT_PULLUP в покое и при нажатии?
- 3. Сколько полных строк размера 1 помещается на высоте 64 пикселя?
- 4. Один щелчок кнопки, а курсор прыгнул на два пункта. Виновник?
- 5. Зачем кадр меню собирается одним проходом и выводится одним display()?
- Три кнопки на D2-D4 с INPUT_PULLUP: в покое HIGH, нажатие прижимает к LOW — резисторы не нужны
- Стрелка в колонке 0, пункты с x = 12: ячейка 6×8 задаёт отступ, 21 символ — потолок строки
- Опрос по фронту с окном 40 мс: один щелчок — один шаг, дребезг глотается окном
- Курсор зажат границами 0 и NUM-1; циклический проход — только для списков длиннее шести строк
- Выбор — действие плюс обратная связь: строка в статусе и печать в Serial на каждое нажатие
Частые вопросы по теме урока
- Почему пункты меню печатаются с x = 12, а стрелка — с x = 0?
- Отступ — две ячейки по 6 пикселей: колонка стрелок отдельная, текст не наезжает на метку даже при самой длинной строке. Стрелка занимает свою ячейку 6×8 и не слипается с текстом
- Что увидит digitalRead на пине с INPUT_PULLUP в покое и при нажатии?
- Подтяжка держит линию на HIGH, нажатие прижимает пин к земле — LOW. Поэтому срабатывание проверяется сравнением с LOW. HIGH и LOW
- Сколько полных строк размера 1 помещается на высоте 64 пикселя?
- Ячейка 6×8: 64 делится на 8 без остатка — восемь строк. Меню занимает три верхние, нижняя строка 56 уходит под статус. 8
- Один щелчок кнопки, а курсор прыгнул на два пункта. Виновник?
- Механика кнопки звенит пять-десять миллисекунд после касания: без окна каждый звон считается новой сменой уровня. Дребезг контактов без окна фильтрации
- Зачем кадр меню собирается одним проходом и выводится одним display()?
- Буфер хранит всё напечатанное: подрисовка курсора поверх кадра без clearDisplay копит следы на прежних местах. Чтобы старые стрелки не оставались призраками на кадре