Собрал я однажды термостат: меню из трёх пунктов, в середине — уставка. Выбираю пункт — открывается экран с крупной цифрой, меняю кнопками, жму выбор — возвращаюсь в меню… и экран тут же открывается снова. Кнопка D4 всё ещё была прижата, когда автомат вернулся в меню, и то же нажатие отработало дважды за одно удержание. Это классика растущего интерфейса: пока экран один, кнопки можно читать как попало; как только экранов два и больше, каждый опрос обязан знать, на каком экране он живёт.
Решение старше Arduino на пару десятков лет — конечный автомат экранов. Заводим переменную состояния, каждый проход loop сначала собирает события кнопок, а потом раздаёт их по switch от имени текущего экрана, и переходы описаны явно: из MENU выбор открывает EDIT, из EDIT — возвращает в MENU. Весь интерфейс укладывается в один switch, и третий экран — это ветка, а не переписывание скетча. За основу беру меню с кнопками из первого урока серии и ручку из второго — сегодня важны не кнопки, а скелет переходов.
Разберём раскладку состояний, полный скетч на два экрана, граблю залипания перехода и то, куда интерфейс растёт дальше: третий экран, экран подтверждения, сохранение настроек между включениями. Всё — на каркасе из первых двух уроков, без новых библиотек.
Два состояния, один switch
Состояния — обычные константы: EKRAN_MENU и EKRAN_EDIT, текущее живёт в переменной ekran. Названия — константы не для красоты: без них через месяц в коде окажутся магические нули и единицы, и поиск по цифре не найдёт место перехода. Порядок каждого цикла жёсткий: опросили кнопки с окном 40 мс и получили события «вверх», «вниз», «выбор» — и только потом раздали их по switch(ekran). Это ключевой момент: сначала события, потом состояние. Если каждый экран начнёт сам читать пины, общие зажимы и окно опроса придётся копировать в каждый экран, а рассинхрон копий — дело двух недель. Значение уставки и позиция курсора — обычные глобальные переменные: экраны их не сбрасывают, поэтому после возврата из подэкрана курсор стоит на том же пункте, откуда ушёл, — контекст пережил переход.
Подэкран рисуется своей функцией: заголовок на размере 2, крупная цифра уставки на размере 3 — семь символов в строке, как посчитано в уроке про размеры шрифтов — и строка подсказки внизу на размере 1. Одно число крупно — не минимализм ради минимализма: настройка — место, где рука на кнопках, а глаза на цифре, лишние строки тут только мешают. Меню при этом живёт своей жизнью и ничего не знает о подэкране, кроме того, что его кто-то открыл. Сюда же ляжет и состояние внутри экрана, если понадобится: мигающий курсор у цифры, экран подтверждения — всё это дополнительные значения той же переменной. Один байт на state и один switch на раздачу масштабируются дальше без сюрпризов.
Adafruit_SSD1306 display(128, 64, &Wire, -1); const int EKRAN_MENU = 0;const int EKRAN_EDIT = 1;int ekran = EKRAN_MENU; String punkty[3];int kurzor = 0;int ustavka = 25; 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("Ustavka sejchas: "); display.print(ustavka); display.display();} void risujEdit() { display.clearDisplay(); display.setTextSize(2); display.setCursor(0, 0); display.print("Ustavka"); display.setTextSize(3); display.setCursor(0, 24); display.print(ustavka); display.print(" C"); display.setTextSize(1); display.setCursor(0, 56); display.print("D4 - nazad v menyu"); display.display();} void risujTekushchij() { if (ekran == EKRAN_MENU) { risujMenu(); } else { risujEdit(); }} void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); pinMode(3, INPUT_PULLUP); pinMode(4, INPUT_PULLUP); punkty[0] = "Start ventilyatora"; punkty[1] = "Ustavka temperatury"; punkty[2] = "Podsvetka: vikl"; risujTekushchij();} void loop() { int up = digitalRead(2); if (up != lastUp && millis() - tUp > 40) { tUp = millis(); lastUp = up; if (up == LOW) { if (ekran == EKRAN_MENU) { if (kurzor > 0) { kurzor--; } } else { if (ustavka > 10) { ustavka--; } } risujTekushchij(); } } int dn = digitalRead(3); if (dn != lastDn && millis() - tDn > 40) { tDn = millis(); lastDn = dn; if (dn == LOW) { if (ekran == EKRAN_MENU) { if (kurzor < 2) { kurzor++; } } else { if (ustavka < 40) { ustavka++; } } risujTekushchij(); } } int ok = digitalRead(4); if (ok != lastOk && millis() - tOk > 40) { tOk = millis(); lastOk = ok; if (ok == LOW) { if (ekran == EKRAN_MENU) { ekran = EKRAN_EDIT; } else { ekran = EKRAN_MENU; } Serial.print("ekran="); Serial.println(ekran); risujTekushchij(); } } delay(5);}Смотрите на обработку D4: в MENU она ставит ekran = EKRAN_EDIT и рисует подэкран, в EDIT — возвращает и рисует меню. Никакого третьего пина не понадобилось: тот же пин, тот же фрагмент опроса, а смысл события определяет состояние. Именно это и есть автомат в самом приземлённом виде. В Serial на каждый переход печатается ekran= с номером: меню — 0, подэкран — 1, и по монитору видно каждый переход без догадок.
Грабля залипания решается уже знакомой формой опроса: срабатываем по фронту — смене уровня с HIGH на LOW, — и пока палец держит кнопку, новых событий нет. Возврат в MENU при ещё прижатой D4 не открывает подэкран повторно: фронт был один, отработан один раз. Отпускание даёт фронт LOW → HIGH, но на него скетч не реагирует вовсе. Кто обрабатывает уровень «кнопка нажата» в каждом проходе — получает мигание меню-подэкран-меню за одно удержание и долго ищет причину не в том месте.
Порядок функций в скетче не случайный. risujMenu и risujEdit ничего не знают друг о друге и не меняют общих переменных: первая рисует список и статус с текущей уставкой, вторая — крупную цифру. risujTekushchij смотрит в ekran и вызывает нужную. Вся логика переходов живёт в одном месте, в обработчике D4, и такой скетч читается сверху вниз без прыжков глазами. Когда экранов станет три, risujTekushchij вырастет до switch с тремя ветками — правка на две минуты, потому что ничто другое в скетче про количество экранов не знает. Это и есть тест на хороший автомат: число экранов упоминается в коде ровно один раз.
Границы уставки от 10 до 40 — не украшение: у каждой настройки есть физический диапазон, и зажим его держит. В подэкране курсорные зажимы из меню ни при чём — здесь зажимается сама уставка, и это вторая половина разницы между навигацией и вводом значения: ходить по пунктам можно циклом, значения — только по паспорту прибора. Выход за диапазон в устройстве с настоящим нагревателем — уже не интерфейсная мелочь, а поход в магазин за новым термосом.
Куда растёт интерфейс
| Состояние | D2 / D3 | D4 | Что на экране |
|---|---|---|---|
| MENU | курсор по пунктам | открыть EDIT | три пункта, стрелка, статус с уставкой |
| EDIT | уставка минус / плюс | вернуться в MENU | крупная цифра и строка подсказки |
| SAVE (рост) | по смыслу | подтвердить | строка «Сохранено» и таймер возврата |
| Третий (рост) | по смыслу экрана | по смыслу экрана | своя функция отрисовки |
Попробуйте в симуляторе две вещи. Первая: откройте подэкран и подержите D4 целую секунду — экран не мигнёт обратно, фронт отработан единожды, автомат спит до следующего нажатия. Вторая: смените уставку, вернитесь и выберите другой пункт — цифра осталась той, какой вы её оставили. Состояния интерфейса и хранимые значения живут раздельно, и это главный признак зрелого скелета: добавить экран — не значит пересобрать всё остальное. Третий тест для макетки: во время удержания D4 покликайте D2 и D3 — в подэкране они правят уставку, и ни одно нажатие не теряется, потому что опрос продолжается каждую итерацию loop. Автомат не блокирует ввод, он только решает, к чему этот ввод применяется.
Рост дальше очевиден. Третий экран — ветка в switch и своя функция отрисовки; экран «сохранено» — ещё одна ветка с возвратом по таймеру на millis, без кнопок вовсе. Значения, которые должны пережить выключение, уходят в энергонезависимую память — с первого байта это разобрано в уроке про чтение и запись EEPROM. А если после очередной ветки экран гаснет целиком, не спешите винить автомат: в первую очередь питание и адрес шины, чек-лист — в уроке про диагностику чёрного экрана.
И про ручку в этой же схеме: автомат экранов не зависит от того, чем ходит курсор. Замените опрос кнопок на тик энкодера из второго урока — switch, границы и перерисовка останутся как есть, потому что они про экраны, а не про ввод. В моём термостате так и вышло: кнопки оставили выбор и возврат, ручка взяла навигацию и правку уставки, и переходы стали короче на одно нажатие. Тот же скелет держит и экраны без ввода вообще: сводка с DHT22 собрана на том же буфере и том же display(), просто без кнопок под ней.
- 1. Где хранится текущий экран в автомате экранов?
- 2. Кнопка D4 прижата, автомат вернулся из EDIT в MENU. Что произойдёт?
- 3. Почему подэкран показывает одно число крупно?
- 4. delay(300) после каждого перехода как лекарство от залипания:
- 5. Курсор после возврата из подэкрана:
- Состояние экрана — переменная ekran: MENU и EDIT, события раздаёт один switch
- Порядок цикла жёсткий: сначала опрос кнопок и события, потом раздача по состоянию — экраны не читают пины сами
- Срабатывание по фронту HIGH → LOW: удержание не даёт двойных переходов, отпускание игнорируется
- Курсор и уставка — глобальные переменные: экраны их не трогают, возврат возвращает контекст
- Рост — ветки switch и свои функции отрисовки; пережить выключение — EEPROM
Частые вопросы по теме урока
- Где хранится текущий экран в автомате экранов?
- Переменная ekran со значениями EKRAN_MENU и EKRAN_EDIT; switch раздаёт события кнопок от имени текущего экрана. В отдельной переменной состояния
- Кнопка D4 прижата, автомат вернулся из EDIT в MENU. Что произойдёт?
- Срабатывание идёт по фронту HIGH → LOW. Пока кнопка держится, новых фронтов нет; отпускание скетч игнорирует. Ничего: фронт был обработан один раз
- Почему подэкран показывает одно число крупно?
- Настройка — место, где рука на кнопках и взгляд на цифре: размер 3 даёт семь символов, чего хватает значению с единицей. Глаза при настройке заняты цифрой, лишние строки мешают
- delay(300) после каждого перехода как лекарство от залипания:
- Треть секунды на переход плюс пропущенные нажатия в паузе. Фронт с окном опроса делает то же без потерь. Тормозит интерфейс и глотает нажатия соседних кнопок
- Курсор после возврата из подэкрана:
- kurzor — глобальная переменная, экраны её не трогают: переходы меняют только ekran, контекст переживает возврат. Остаётся на прежнем пункте: значения живут вне экранов