Терморегулятор для инкубатора я собрал за вечер, а потом три недели таскал в сарай ноутбук. Плата не ломалась — ломалось удобство: порог был зашит константой в скетче, и поправить тридцать семь и восемь на тридцать восемь означало перепрошивать устройство на столе у окна, при том что закладка яиц идёт двадцать один день без выходных. Третий визит с ноутбуком под мышкой поставил точку: пульт обязан жить на самом устройстве. Экран уже стоял, оставалось главное — понять, как устроить выбор настроек двумя кнопками, потому что третья рука птице не нужна, а свободных пинов оставалось ровно два.
Соберём полное меню на LCD 16x2: список пунктов, курсор, листание по кругу и переключение выбранного пункта. По дороге разберём два решения, без которых меню на кнопках не живёт: фронт нажатия (реакция на момент нажатия, а не на «держу») и дребезг, который однажды заставил меня двадцать минут подозревать исправную кнопку. База у нас уже есть: в уроке про мини-меню с курсором был выбор из списка, сегодня доводим каркас до настоящего пульта, где каждая строка ещё и управляет своим прибором.
Анатомия меню: пункты, состояния, курсор
Меню на символьном дисплее — это три переменные и одна функция рисования. Имена пунктов лежат в двумерном массиве: char names[3][12] — три строки по двенадцать символов, ровно так мы хранили списки в уроке про массивы. Двенадцать — не магия, а размер колонки с запасом: самое длинное слово пункта «Signal» занимает шесть знакомест, и даже если захотите переименовать его в «Sirena», рамка не треснет. Рядом — массив состояний: у каждого пункта свой выключатель, и хранить его нужно именно списком, а не россыпью bool-переменных, иначе добавление четвёртого пункта превращается в правку половины скетча. Индекс current помнит выбранный пункт; курсор рисую символом «>» в нулевой колонке — двигаться по сетке экрана мы научились ещё в разборе setCursor и print.
Экран 16x2 показывает два пункта сразу: выбранный — с курсором на верхней строке, следующий за ним — на нижней. Листание идёт по кругу: после последнего пункта снова первый. После пары пультов я убедился, что на двух кнопках круг дешевле кнопки «назад»: до любого пункта можно дойти в одну сторону, и заблудиться негде. Перерисовываю меню только в момент события: каждый print по I2C — это отдельные пакеты на шине, строка меню стоит около двух миллисекунд, но причина даже не во времени — экран, который перерисовывается без причины, выглядит нервным и быстрее утомляет взгляд.
Мелочь, на которой спотыкаются новички — и я спотыкался: при переключении OFF на ON без хвостовых пробелов на экране остаётся мусор. Три буквы «OFF» занимают три знакоместа, две буквы «ON» — два, и старая «F» живёт на экране до следующей полной перерисовки. Поэтому в коде печатаются «ON » и «OFF» с одинаковым занимаемым местом: пробел — это тоже символ, и затирание хвостов мы подробно разбирали в уроке про числа. Учитывайте это с первой версии меню — потом поиск такой мелочи на живом пульте занимает вечер.
#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); char names[3][12] = {"Pompa", "Lampa", "Signal"};bool state[3] = {false, true, false};int current = 0; void drawMenu() { lcd.clear(); int nxt = current + 1; if (nxt > 2) nxt = 0; // листание по кругу lcd.print("> "); lcd.print(names[current]); lcd.print(": "); if (state[current]) lcd.print("ON "); else lcd.print("OFF"); lcd.setCursor(0, 1); lcd.print(" "); lcd.print(names[nxt]); lcd.print(": "); if (state[nxt]) lcd.print("ON "); else lcd.print("OFF");} void setup() { lcd.init(); lcd.backlight(); drawMenu();} void loop() { // кнопки читаются здесь: полный скетч в тренажёре ниже}Теперь кнопки, и здесь два правила. Первое: реагируем на фронт — момент перехода от «отпущена» к «нажата». В скетче живут предыдущее чтение и текущее: событие засчитываем, только когда кнопка стала LOW, а в прошлый раз была HIGH. Если читать состояние напрямую, одно удержание превратится в листание со скоростью тысяч срабатываний в секунду. Кнопки сажаем с подтяжкой INPUT_PULLUP, как в уроке про digitalRead: внутренний резистор подтяжки держит вход на HIGH, а нажатие прижимает его к земле — через кнопку течёт доля миллиампера, внешние резисторы не нужны. В моём пульте D2 листает вниз, D3 переключает выбранный пункт.
Второе правило: дребезг. Контакты механической кнопки при нажатии дребезжат 10–20 миллисекунд, и один физический щелчок выглядит для платы как серия из трёх-пяти. Лечение, которого хватает в девяти случаях из десяти, — пауза delay(20) сразу после засчитанного события: она срезает хвост дребезга. Для строгих проектов есть отдельный урок про debounce с millis-фильтром и замером времени, но в меню, где события редки, двадцатимиллисекундной заглушки достаточно — я живу с ней во всех дисплеевых сборках и пока ни о чём не пожалел. А когда кнопок на пульте становится больше двух, пины начинает экономить матричная клавиатура: ниже — интерактивная песочница, понажимайте её клавиши и посмотрите, как сканер строк находит нажатие по тому же фронт-принципу и рапортует о нём в мини-мониторе.
Опрос строк и столбцов: как подтяжки ловят нажатия и почему дребезг не страшен.
Подгружаем интерактив…
Тренажёр: пульт на двух кнопках
В тренажёре — меню теплицы из трёх пунктов: помпа, лампа, сигнал. Кнопка на D2 листает пункты по кругу, кнопка на D3 переключает состояние выбранного пункта ON/OFF. Обе подключены к D2 и D3 с подтяжкой: в симуляторе, как и на реальной плате, нажатие прижимает вход к земле. Сценарий проверки: пройдите по кругу все три пункта и вернитесь к первому — если где-то запнулись, смотрите сравнение current с длиной списка. Затем переключите помпу и убедитесь, что она сменила OFF на ON, а соседний пункт внизу остался нетронутым.
Почему пункты — переключатели, а не действия? Потому что пульт обязан показывать состояние: из меню всегда видно, включена ли помпа, даже когда вы стоите на другом пункте — следующий по кругу всегда на глазах. Если пункту нужно действие вроде сброса, ему заводится собственная строка-пункт; это уже мостик к следующему уроку, где пункты научатся открывать настройки и менять числа. Для проектов, где пунктов больше трёх, берите экран 20x4: там видны четыре строки и навигация честнее, правда, адресация строк у 20x4 с сюрпризами, о которых я писал в отдельном разборе.
И про числа в пунктах: когда к пункту прирастает изменяемое значение — порог, время, длительность, — понадобятся ровные колонки и затирание хвостов; это мы разбирали в уроке про дроби и ведущие нули. Само меню от чисел не ломается: значение — такая же часть строки пункта, как ON и OFF, и рисуется тем же print() с той же перерисовкой по событию. А цена расширения — хороший тест каркаса: в моём инкубаторе четвёртый пункт добавился тремя строками — слово в names, место в state и единица в сравнениях границ; если у вас на это уходит больше, значит где-то размер списка вшит руками вместо одного счётчика.
| Событие | Что меняется в памяти | Что рисуем |
|---|---|---|
| Фронт нажатия D2 | current + 1, по кругу через ноль | Обе строки заново: курсор и следующий пункт |
| Фронт нажатия D3 | state[current] инвертируется | Ту же пару строк: сменится только ON/OFF с учётом хвостовых пробелов |
| Между нажатиями | Ничего | Ничего: экран не трогаем, loop крутится вхолостую и опрашивает кнопки |
- Добавьте четвёртый пункт Vent: строку в names, место в state, поправьте границы листания с 2 на 3 — и проверьте, что круг замкнулся.
- Поменяйте кнопки местами: D2 — переключение, D3 — листание. Смысл скетча измениться не должен, только имена пинов.
- Заведите счётчик переключений и выведите его в правом углу нижней строки — пригодится затирание хвостов из урока про числа.
- Замените delay(20) на millis-фильтр из урока про debounce и убедитесь, что скетч не потерял ни одного нажатия.
- Меню — это массив имён, массив состояний и индекс current: новый пункт добавляется строкой в массив, а не правкой полскетча
- Реагируйте на фронт нажатия через пару prev и current: прямое чтение превращает удержание в бешеное листание
- Дребезг живёт в первых 10–20 мс после щелчка: delay(20) после события срезает его в меню-скетчах без дальнейшей церемонии
- Экран трогаем только при событии: пара строк по I2C стоит миллисекунды, а главное — дисплей не мигает без причины
- Листание по кругу дешевле третьей кнопки: выбранный и следующий пункты всегда на виду у пользователя
- 1. Почему меню «листает само», если читать кнопку без фронтов?
- 2. Сколько миллисекунд обычно дребезжат контакты механической кнопки?
- 3. Где в скетче урока лежат имена пунктов и их состояния?
- 4. Что означает «листание по кругу» в скетче урока?
- 5. Зачем перерисовывать меню только при событии?
Частые вопросы по теме урока
- Почему меню «листает само», если читать кнопку без фронтов?
- Без пары prev/current каждое чтение LOW засчитывается как новое нажатие: loop успевает провернуть тысячи событий за одно удержание кнопки. Удержание читается тысячи раз в секунду, и каждый цикл становится событием
- Сколько миллисекунд обычно дребезжат контакты механической кнопки?
- Типичные 10–20 мс в момент щелчка: именно их срезает delay(20) после засчитанного события, и потому меню перестаёт прыгать. 10–20 мс
- Где в скетче урока лежат имена пунктов и их состояния?
- names[3][12] и state[3]: новый пункт — это строка в массиве, а не правка логики по всему скетчу. В двумерном массиве имён и массиве состояний
- Что означает «листание по кругу» в скетче урока?
- Сравнение current с длиной списка и сброс в ноль: до любого пункта можно дойти в одну сторону без кнопки «назад». После последнего пункта курсор возвращается к первому
- Зачем перерисовывать меню только при событии?
- Перерисовка пары строк стоит миллисекунды, но главная выгода — спокойный экран: дисплей меняется только тогда, когда что-то действительно изменилось. Экран не мигает без причины, а loop не тратит время на пустую отрисовку