У ночника из урока про switch/case была одна кнопка: каждое нажатие листало режимы по кругу. Для меня это работало, пока ночник не увидели гости. Чтобы просто включить ровный свет, надо было проехать маяк и тревогу — в тёмной комнате вспыхивало, пищало, и каждый раз приходилось извиняться. Одной кнопки на четыре режима хватило ровно до первого постороннего человека: круговое листание честно доводит до нужного пункта, но каждый раз через все соседние.
В этом уроке строим интерфейс, как у микроволновки или термостата: кнопка «листать» бегает по пунктам меню, кнопка «выбрать» подтверждает, светодиод показывает принятый режим. У прибора появляется два состояния — просмотр списка и работа с подтверждённым режимом, — и листание в рабочем состоянии обязано быть безобидным. Порядок пунктов тоже не случайность: список начинается с «Выключено», и после включения платы устройство стоит в известной точке — индекс на нуле, режим подтверждён нулевой. Включил — и прибор уже предсказуем, никаких «а что он сейчас делает». Всё соберём на двух кнопках и одном диоде в симуляторе, а грабли реальных кнопок обсудим отдельно и честно.
Индекс по кругу и фронт кнопки
Меню — это переменная indeks и массив названий. Нажатие «листать» прибавляет единицу, дошло до последнего пункта — снова ноль: if (indeks > PUNKTOV - 1) indeks = 0. Названия пунктов лежат в массиве строк String punkty[3], и печать Serial.println(punkty[indeks]) показывает человеку, где он находится, — глазами следить за светодиодами не нужно, тем более что пунктов будет три, а диод один. Список названий живёт отдельно от логики листания: переименовать пункты или добавить четвёртый — значит поправить один массив, а не лестницу условий.
Вторая половина механики — фронт. Обе кнопки сидят на INPUT_PULLUP и читаются как «LOW при прошлом HIGH»: ровно тот приём из урока про кнопку, который отличает нажатие от удержания. Без фронта зажатая кнопка листала бы пункт на каждом обороте loop — сотни раз в секунду; с фронтом одно нажатие даёт ровно один лист. Переменных-памятки нужно две: lastListat и lastVybor, по одной на кнопку — я однажды обобщил их в одну и полчаса не понимал, почему выбор срабатывает сам.
И важное дизайнерское решение, которое я сначала проморгал: пока вы листаете меню, подтверждённый режим продолжает работать. Листание — это только разговор, оно ничего не меняет; новое значение переменной rezhim присваивается только по кнопке «выбрать». Гость может полистать пункты в тишине, не рискуя включить тревогу мимоходом — тот недостаток круговой листалки из моей истории, из-за которого всё и затевалось.
const int KN_LISTAT = 2;const int KN_VYBOR = 3;const int PUNKTOV = 3; String punkty[3] = {"Vyklyucheno", "Rovnyj svet", "Mayak"};int indeks = 0;int rezhim = 0; // подтверждённый выбор, меняется только кнопкой «выбрать»int lastListat = HIGH;int lastVybor = HIGH; void setup() { pinMode(KN_LISTAT, INPUT_PULLUP); pinMode(KN_VYBOR, INPUT_PULLUP); Serial.begin(9600); Serial.println("Menyu: 2 - listat, 3 - vybor");} void loop() { int listat = digitalRead(KN_LISTAT); if (listat == LOW && lastListat == HIGH) { indeks = indeks + 1; if (indeks > PUNKTOV - 1) { indeks = 0; } Serial.print("Menyu "); Serial.print(indeks + 1); Serial.print(": "); Serial.println(punkty[indeks]); } lastListat = listat; int vybor = digitalRead(KN_VYBOR); if (vybor == LOW && lastVybor == HIGH) { rezhim = indeks; Serial.print("Vybrano: "); Serial.println(punkty[rezhim]); } lastVybor = vybor;}Скелет уже работающий: в Serial видно и листание, и подтверждение. Подключим поведение режимов — и здесь пригодится весь каркас ночника: rezhim 0 гасит диод, rezhim 1 держит его ровно, rezhim 2 мигает по millis() с периодом 600 мс, 300 горит и 300 темнота. Мигание — обязательно через millis(), не через delay(): если маяк поставит паузу в loop, кнопки замрут на треть секунды, и половина нажатий по листанию пропадёт. Как устроен такой таймер и почему он спасает отзывчивость, разобрано в уроке про millis вместо delay — здесь берём готовый приём.
При подтверждении есть одна мелочь, которую я вписал не сразу: сброс флагов анимации. Переключение с маяка на ровный свет без gorim = 0 и digitalWrite(SVET, LOW) оставляло диод в случайном состоянии — горящим, если вспышка была в разгаре. Правило простое: при смене режима устройство приводится в известное состояние явно, а не «пусть само доедет».
Про габариты меню. Три-восемь пунктов две кнопки обслуживают отлично; когда пунктов больше десяти, листать по одному становится мучением, и пора смотреть в сторону матричной клавиатуры — цифры дают прямой доступ к пункту. Промежуточный вариант без второй кнопки — потенциометр-листалка, где пункт выбирается положением ручки, — мы делали расчёт через map в уроке про dimmer; но у ручки нет кнопки «ок», и выбор без подтверждения для меню опасен.
Грабли: дребезг, автоповтор и неявное состояние
В симуляторе кнопки идеальные: одно нажатие — один фронт. На реальной плате контакты дребезжат 10–20 мс, и одно нажатие превращается в два-четыре фронта — меню листает через пункт, и вы снова ищете нужный. Лечение — антидребезг на 50–100 мс, целиком разобранный в отдельном уроке; в скетче этого урока я оставляю честный delay(300)-стиль только там, где он не мешает, но для меню нормальным решением будет millis-гейт. Знать об этом надо до сборки на живой плате: в симе дефект не воспроизведётся, и это может создать ложное чувство готовности.
Вторая грабля — обратная: удержание кнопки листает ровно один раз, автоповтора нет. Для меню из трёх пунктов это комфортно, для списка из двадцати — спорт. Автоповтор пишется отдельно: после первого листания запускается millis-таймер на 400 мс, и пока кнопка зажата, каждые 400 мс листает ещё раз — так ведут себя все нормальные интерфейсы, от клавиатуры до магнитолы. Шаг повтора я держу вдвое-втрое больше паузы первого листания, иначе медленное нажатие превращается в два. Третье — неявное состояние: если rezhim в старте скетча задать криво, например семёркой, диод уедет в неизвестность; у меня последняя ветка else гасит светодиод именно поэтому — мусорное значение должно приводить устройство в известное состояние, как default в конечном автомате светофора. И мелочь для отладки: печать пункта стоит внутри ветки фронта, а не после неё — иначе Serial печатал бы один и тот же пункт на каждом обороте.
| Интерфейс | Когда уместен | Цена |
|---|---|---|
| Одна кнопка по кругу | 2–3 режима | Нужный пункт проезжается через все соседние — мой ночник |
| Две кнопки: листать + выбрать | 3–8 пунктов | Два пина и чуть больше кода — наш случай |
| Потенциометр-листалка | До 6 пунктов | Крутишь ручку, пункт из map(), но кнопки «ок» нет — выбора без подтверждения не бывает |
| Матричная клавиатура | 10+ пунктов | Восемь пинов и скан-скетч, зато прямой доступ к пункту |
| Дисплей + две кнопки | Сложные настройки | Пункты видно глазами; это уже территория LCD и OLED |
В симуляторе макетка собирается сама: кнопки сядут на D2 и D3, светодиод — на D12. В скетче это pinMode(2) и pinMode(3) — пины назначены симулятором, на своей плате перенесите константы, если разводка другая. Нажимайте «листать»: в Serial бегут пункты 1 из 3, 2 из 3, 3 из 3, и диод молчит — до подтверждения ничего не меняется, даже если маяк в этот момент мигает. Проехали нужный пункт — продолжайте листать, меню ходит по кругу и вернётся.
Кнопка «выбрать» применяет пункт: 0 — всё выключено, 1 — ровный свет, 2 — маяк с периодом 600 мс. После выбора Serial печатает Vybrano: и название. Попробуйте листать меню во время маяка — вспышки не сбиваются: листание и работа живут в одном loop, но не мешают друг другу, потому что режим меняется только по подтверждению. И обратная проверка: промотайте меню на «Ровный свет» и не нажимайте «выбрать» — маяк продолжит мигать. Меню — не выключатель: пока подтверждения не было, работает то, что подтверждено раньше.
- Добавьте четвёртый пункт «Тревога»: зуммер на D8 пиксает раз в 300 мс по millis-таймеру (зуммер подключается в макетке одной кнопкой). Пунктов станет четыре — правки ждут массив punkty и константа PUNKTOV, а логика листания останется прежней.
- Сломайте фронт нарочно: уберите lastListat из условия и оставьте if (listat == LOW). Зажмите кнопку в симуляторе — пункты полетят со скоростью loop. Верните фронт и запомните это ощущение как цену одной переменной.
- Сделайте автоповтор: после первого листания держите millis-таймер на 400 мс и листайте повторно, пока кнопка зажата. Проверка: удержание должно дать пункты с ровным шагом, а не лавину.
- Листание в обратную сторону: переезжайте с прибавления на вычитание, не забыв case для минус единицы — если (indeks < 0) indeks = PUNKTOV - 1. Проверьте круг в обе стороны на границах массива.
- Две кнопки — «листать» и «выбрать» — снимают главный недостаток круговой листалки: нужный пункт больше не добирается через все соседние режимы.
- У устройства с меню два состояния: просмотр (листание ничего не меняет, режим продолжает работать) и работа (подтверждённый rezhim).
- Обе кнопки читаются по фронту LOW при прошлом HIGH: одно нажатие — одно событие; у каждой кнопки своя переменная-памятка.
- Индекс ходит по кругу через if (indeks > PUNKTOV - 1) indeks = 0; названия пунктов — массив строк, Serial показывает, где вы находитесь.
- На реальной плате дребезг листает по два-три пункта: антидребезг 50–100 мс нужен до сборки, в симуляторе дефект не воспроизводится.
- 1. Что происходит с подтверждённым режимом, пока вы листаете меню?
- 2. Кнопку «листать» зажали и держат. Сколько листаний увидит меню с фронт-детектором?
- 3. На реальной плате меню листает по два-три пункта за одно нажатие. Главная причина?
- 4. indeks дошёл до 2 при PUNKTOV = 3. Что вернёт листание после if (indeks > PUNKTOV - 1) indeks = 0;?
- 5. Светодиод горел маяком, вы выбрали пункт «Ровный свет». Почему в скетче при подтверждении стоит digitalWrite(SVET, LOW)?
Частые вопросы по теме урока
- Что происходит с подтверждённым режимом, пока вы листаете меню?
- Листание меняет только indeks, а переменную rezhim трогает исключительно кнопка «выбрать». Поэтому можно спокойно полистать пункты, не задев работающий режим, — в этом и была цель всей затеи с двумя кнопками. Продолжает работать — листание ничего не меняет
- Кнопку «листать» зажали и держат. Сколько листаний увидит меню с фронт-детектором?
- Фронт ловит переход HIGH-LOW, то есть момент нажатия. Пока кнопка остаётся зажатой, new фронта нет, и листание не повторяется — автоповтор пишется отдельным millis-таймером. Одно — на нажатие
- На реальной плате меню листает по два-три пункта за одно нажатие. Главная причина?
- Механические контакты дребезжат 10–20 мс после замыкания, и каждый скачок читается как новое нажатие. Лечение — антидребезг на 50–100 мс. Проверка простая: тот же скетч в симе идеален, а на плате листает через пункт. Дребезг контактов даёт несколько фронтов за 10–20 мс
- indeks дошёл до 2 при PUNKTOV = 3. Что вернёт листание после if (indeks > PUNKTOV - 1) indeks = 0;?
- После третьего пункта (индекс 2) нажатие даёт 3, условие 3 > 2 срабатывает, и индекс обнуляется. Меню ходит по кругу без выхода за границу массива. Станет 0 — пункт вернётся в начало
- Светодиод горел маяком, вы выбрали пункт «Ровный свет». Почему в скетче при подтверждении стоит digitalWrite(SVET, LOW)?
- При смене режима устройство приводится в известное состояние явно: флаг gorim обнуляется, диод гасится. Без этого новый режим стартует с остатка чужой анимации — диод может оказаться в случайном состоянии. Чтобы сбросить анимацию в известное состояние, а не «доезжать» случайно