Термостат в мастерской я довёл до трёх кнопок и упёрся в арифметику рук: выбрать пункт — нажатие, открыть его — второе, вернуться — третье. На столе это терпимо, но с деталями в руках прибор переворачивать неудобно, и меню на шесть пунктов превратилось в гимнастику большого пальца. Ручка KY-040, которую мы уже ставили в меню в уроке про энкодер, снимала прокрутку, но оставалась вторая половина проблемы: подэкраны. Выбор пункта должен не печатать строку статуса, а открывать отдельный экран — со своей отрисовкой, своими реакциями на поворот и своим выходом.
Скелет переходов я раньше собирал на кнопках в уроке про автомат экранов — там же лежит и ловушка с минус-один при листании назад. Сегодня достраиваю второй этаж: ручка работает в двух ролях на разных экранах, клик ручки ходит вглубь и обратно, а счёт щелчков живёт в прерывании и переживает долгую отрисовку кадра. Отдельная серия граблей ждёт нас в железе: дребезг контактов ручки, от которого курсор прыгает через пункт.
План такой: роли ручки по экранам, полный скетч с обработчиком прерывания и окном защиты на SW, симулятор с живым меню и разбор двух симптомов — двойной шаг от одного щелчка и потерянные щелчки при вращении во время отрисовки.
Слово про объём: скетч выходит на сотню строк, и половина из них — две функции отрисовки, по одной на экран. Это нормально и даже правильно: каждый экран рисует себя сам, целиком, с чистого стекла, и ни один экран не знает про соседа ничего. Именно такая изоляция делает добавление четвёртого и пятого пункта меню делом на десять минут, а не на вечер с отладчиком.
Роли ручки: список, значение, выход
Нестрогое правило, выведенное на трёх приборах: ручка на каждом экране делает ровно одно. В меню — двигает курсор по пунктам, в подэкране — правит значение, и больше ничего. Как только у ручки на одном экране заводится вторая функция, пользователь прибора, то есть я через неделю, начинает путаться в собственных жестах. Глубина вложенности при этом спокойно держится в два этажа — меню и его экраны. Третий этаж на 64 пикселях высоты уже не читается: нет места показать и заголовок, и путь назад.
Пунктов верхнего уровня я держу пять-шесть: на прокрутку пяти пунктов уходит четверть оборота — у KY-040 двадцать щелчков на оборот с фиксацией, палец чувствует каждый. Список длиннее десяти ручка листает втрое быстрее кнопок, но тогда в углу кадра нужен счётчик вида 3/7: без него глаз теряет протяжённость списка. Знак курсора рисую стрелкой в первой колонке; выделение строкой целиком — отдельная механика, разобранная в уроке про инверсное меню.
Скорость руки стоит знать заранее: уверенное вращение — два-три оборота в секунду, то есть сорок-шестьдесят щелчков, по 15-25 мс на щелчок. Любая пауза в обработке длиннее этого интервала съедает шаги, отсюда и требование короткого прерывания, к которому мы идём. Меню, чей кадр перерисовывается дольше 20 мс, быстрые повороты не догонит даже с прерыванием — в этом случае двигать приходится саму отрисовку, а не обработчик.
Выход из подэкрана — отдельная развилка, и тут у меня жёсткое мнение: только повторный клик SW. Поворот до упора, длинное нажатие, таймаут с автовыходом — всё это я пробовал, и каждый вариант через месяц возвращал меня в меню без сохранённого значения. Клик — событие редкое и осознанное: им заходят, им и выходят. Повернутые в редакторе щелчки при этом сразу пишутся в переменную значения — отдельного «сохранить» пользователь всё равно не нажмёт.
| Событие ручки | В меню (экран 0) | В редакторе (экран 1) |
|---|---|---|
| Поворот по часовой | курсор на пункт вниз, зажим на последнем | значение плюс пять, зажим на сотне |
| Поворот против часовой | курсор вверх, зажим на первом | значение минус пять, зажим на нуле |
| Клик SW | войти в подэкран выбранного пункта | вернуться в меню, значение уже записано |
| Щелчки во время отрисовки | копятся в переменной, loop разберёт после кадра | то же самое: ни один шаг не пропадает |
Счёт в прерывании, рисование в loop
Счёт щелчков переезжает в обработчик прерывания по CLK: на каждом фронте один digitalRead(DT) подсказывает направление, и переменная шагов ползёт вверх или вниз. Обработчик обязан оставаться коротким — пара арифметических действий и выход, всю отрисовку делает loop, когда заметит, что счётчик изменился. Кадр при этом планируется по millis без единого delay — разбор подхода из соседнего раздела здесь обязательный фон. Переменную, которую трогает прерывание, помечаем volatile: без этого слова компилятор вправе держать её копию в регистре, и loop не увидит изменений.
Почему не опрос в loop: полная перерисовка кадра на I2C занимает 25-30 мс, и всё это время loop занят передачей буфера. Вращая ручку в этот момент, вы делаете до пары щелчков в слепую зону — именно так теряются шаги, на которые жалуются в каждом втором треде про энкодеры. Прерывание срабатывает всегда, а щелчки копятся в переменной до освобождения loop: экран отстаёт на кадр, но счёт не теряет ничего.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); const int CLK = 2; // CLK tolko na D2/D3 - tam preryvaniyaconst int DT = 3;const int SW = 4; volatile int shagi = 0; // shchelchki s proshlogo razbora v loop int ekran = 0; // 0 = menu, 1 = redaktor porogaint punkt = 0; // 0..2int porog = 40;int lastSw = HIGH;unsigned long tKlik = 0; void tick() { if (digitalRead(DT) == HIGH) shagi++; else shagi--;} void risujMenu() { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.print("MENU"); for (int i = 0; i < 3; i++) { display.setCursor(0, 16 + i * 16); if (i == punkt) display.print("> "); else display.print(" "); display.print(i + 1); display.print(". punkt "); display.print(i + 1); } display.setCursor(0, 56); display.print("SW - voyti, ruchka - vybor"); display.display();} void risujRedaktor() { display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.print("POROG"); display.setTextSize(2); display.setCursor(0, 16); display.print(porog); display.setTextSize(1); display.setCursor(0, 48); for (int i = 0; i < porog / 5; i++) display.print("*"); display.setCursor(0, 56); display.print("SW - nazad, ruchka - znachenie"); display.display();} void pererisuj() { if (ekran == 0) risujMenu(); else risujRedaktor();} void setup() { pinMode(CLK, INPUT); pinMode(DT, INPUT); pinMode(SW, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(CLK), tick, RISING); Serial.begin(9600); risujMenu();} void loop() { if (shagi != 0) { int s = shagi; shagi = 0; if (ekran == 0) { punkt = constrain(punkt + s, 0, 2); Serial.print("punkt="); Serial.println(punkt); } else { porog = constrain(porog + s * 5, 0, 100); Serial.print("porog="); Serial.println(porog); } pererisuj(); } int sw = digitalRead(SW); if (sw != lastSw && millis() - tKlik > 40) { tKlik = millis(); lastSw = sw; if (sw == LOW) { ekran = 1 - ekran; Serial.print("ekran="); Serial.println(ekran); pererisuj(); } } delay(2);}Зажимы здесь — часть интерфейса, а не перестраховка: курсор упирается в первый и последний пункт, значение — в ноль и сто. Лишний щелчок за границей просто не двигает индекс, зато меню не закручивается в кольцо: кольцевая навигация сбивает представление, где начало списка. Порог в редакторе я меняю пятёрками — щелчок равен пяти, оборот ручки сотне, и уставка от нуля до ста проходит за один оборот.
Клик SW обслуживается в loop с окном защиты 40 мс: дребезг кнопки даёт серию фронтов в первые миллисекунды, и без окна прибор заходил бы в подэкран и тут же выходил. Внутри окна клик игнорируется целиком — дешевле, чем разгребать последствия. Сам переход перерисовывает экран целиком с чистого clearDisplay, и переходной каши на стыке не видно: кадр уходит одним display() — механику буфера мы считали в уроке про clearDisplay и display().
Когда подэкранов станет несколько, переменная из нулей и единиц разрастётся в настоящий автомат: отдельное имя на каждый экран, переходы расписаны явно, поворот и клик раздаются по текущему экрану через switch. Скелет при этом не меняется — растёт только развилка. У меня контроллер полива дошёл до пяти экранов за два месяца, и всё это время обработчик прерывания и окно защиты на кнопке не переезжали ни на строчку.
В симуляторе ручка на макетке подаёт на CLK фронт, а на DT — уровень направления, и скетч разбирает их так же, как разобрал бы настоящую ручку: прерывание копит шаги, loop раздаёт их по текущему экрану. Покрутите ручку в меню — курсор ходит по трём пунктам и упирается в края; клик SW открывает редактор, там те же щелчки правят порог пятёрками, полоса звёздочек растёт и тает. Второй клик возвращает в меню, и Serial печатает каждый переход строкой ekran=.
На реальной плате к схеме добавится дребезг, которого в симуляторе нет: контакты KY-040 механические, и один щелчок может отработать двумя-тремя фронтами. Прерывание от этого не защищает — оно честно посчитает каждый фронт; фильтр ставят железом или разбором полного цикла сигнала, и об этом рамка ниже. А вот потерю щелчков прерывание лечит безоговорочно: покрутите ручку прямо в момент перерисовки — после кадра курсор доедет туда, куда вы докрутили, потому что счёт живёт в переменной, а не в моменте loop.
Если после переноса скетча на плату часть щелчков пропала — ищите не в обработчике, а в том, что вы вернули опрос в loop или вставили delay в теле кадра. По моему опыту, после этого пункта ручка перестаёт слыть глючной в девяти случаях из десяти; десятый лечится конденсатором.
Serial-лог здесь — рабочий инструмент, а не украшение. Крутите ручку медленно и считайте щелчки пальцем: напечатанных строк punkt= должно выйти ровно столько же. Расхождение сразу показывает направление поиска — лишние строки говорят о дребезге и конденсаторе, недостающие о потерянных шагах и затянутом кадре. Осциллограф для этого не нужен: палец и монитор чинят меню быстрее.
- 1. Сколько сигнальных линий у KY-040 и что делает каждая?
- 2. Счёт щелчков переехал в опрос loop, а кадр на I2C тянется 25-30 мс. Что будет при вращении во время отрисовки?
- 3. Курсор прыгает через пункт от одного щелчка. Первая версия причины?
- 4. Как по правилам этого урока выйти из подэкрана в меню?
- 5. Что подсказывает обработчику направление поворота?
- Ручка на каждом экране делает одно: в меню — курсор, в подэкране — значение; вложенность держат в два этажа
- Клик SW — вход и выход: таймауты и повороты до упора теряют значения, клик — нет
- Счёт щелчков живёт в volatile-переменной под прерыванием по CLK; опрос в loop теряет шаги за 25-30 мс отрисовки
- Зажимы на краях списка и диапазона — часть интерфейса: меню не закручивается кольцом
- Двойной шаг лечат 100 нФ на CLK/DT или таблицей квадрата; millis внутри ISR на AVR не тикает
Частые вопросы по теме урока
- Сколько сигнальных линий у KY-040 и что делает каждая?
- CLK/DT — квадратурная пара щелчков, SW — кнопка под ручкой. Направление узнают уровнем DT в момент фронта CLK, отдельной линии направления нет. Три: CLK даёт фронты, DT подсказывает направление, SW — нажатие на ручку
- Счёт щелчков переехал в опрос loop, а кадр на I2C тянется 25-30 мс. Что будет при вращении во время отрисовки?
- Прерывание срабатывает всегда и копит шаги в переменной, а опрос физически не выполняется, пока идёт display(). Отсюда классика пропущенных шагов. Часть щелчков пропадёт: опрос спит, пока loop занят передачей буфера
- Курсор прыгает через пункт от одного щелчка. Первая версия причины?
- Механические контакты дребезжат 1-5 мс. Лечится конденсатором 100 нФ или полным разбором квадрата состояний, но не окном по millis внутри ISR. Дребезг контактов: один фронт CLK отработал серией
- Как по правилам этого урока выйти из подэкрана в меню?
- Клик — событие редкое и осознанное: им заходят, им и выходят. Таймауты и повороты до упора через месяц возвращают в меню без сохранённого значения. Нажать SW ещё раз: тот же клик, что открывал подэкран
- Что подсказывает обработчику направление поворота?
- Внутри обработчика один digitalRead(DT): HIGH — по часовой, LOW — против. CLK только сообщает, что щелчок случился. Уровень на DT в момент фронта CLK