Поливалка в теплице довела меня до этого урока. У прибора было три режима: полив по часам, по датчику влажности и выключено. Переключал я их кнопкой на корпусе — одно нажатие, следующий режим, — и чтобы дойти до нужного, приходилось жать дважды, а то и трижды, глядя на плату в упор и вспоминая, какой режим третий, а какой первый. В теплице, где на плате оседает пыль, а свет слепит, это занятие на грани фольклора. Пульт лежал там же, на полке, и листал телевизор — я тогда ещё не понимал, что у него в руках готовый пульт управления прибором.
Идея: CH+ и CH- листают пункты меню на экране, OK запускает выбранный режим, EQ возвращает к списку. Экран показывает, где вы находитесь и что включится, — и прибор наконец читается с двух метров, как любая нормальная техника. Всё управление сводится к маленькому конечному автомату, и это не пугающее слово из курса теоретической кибернетики, а одна переменная state и switch по ней — мы уже строили такую конструкцию на светофорном автомате.
Соберём это на железе из раздела: приёмник по схеме из урока про VS1838B, LCD 16x2 по I2C — подключение и адреса 0x27/0x3F разобраны в уроке про первый текст, повторяться не буду. Здесь — логика: состояния, переходы, курсор и применение режима к реальной нагрузке.
Пара слов о нагрузках, чтобы дальше говорить предметно. Помпа на 12 В у моей поливалки ест порядка 200 мА — цифровой пин Uno отдаёт 20–40 мА, поэтому насос стоит за MOSFET или реле, как мы разбирали на транзисторной схеме: пин управляет ключом, ключ — мотором. Зуммер сигнализации питается напрямую, ночник — светодиод через резистор 220 Ом. Меню при этом одинаковое для всех трёх: автомату всё равно, что висит за пином, он только выбирает ветку применения — и это его главное достоинство. Кнопку на корпусе я при этом не выпаиваю: она остаётся аварийным путём на случай севшей батарейки в пульте.
Автомат из двух состояний
Состояний два: NAV — навигация по списку, RUN — прибор работает в выбранном режиме. В NAV кадры CH+ и CH- двигают menuIndex по кругу через остаток от деления, OK переводит автомат в RUN и включает режим, имена которого лежат в массиве строк под тем же индексом. В RUN все кнопки кроме EQ игнорируются, EQ глушит нагрузку и возвращает список. Почему нельзя обойтись без состояний и просто реагировать на кнопки? Потому что одна и та же команда должна значить разное в зависимости от того, что происходит на экране: OK в списке — запуск, OK в режиме — ничего или повторный пик зуммера. Это и есть автомат: поведение определяется парой «состояние плюс команда».
Рисование экрана я целиком беру из урока про мини-меню: стрелка в первом столбце активной строки, печать с хвостом из пробелов, перерисовка только при смене пункта. Там же объяснено, почему lcd.clear() в каждом loop() превращает экран в стробоскоп, — здесь я просто вызываю clear() при смене состояния, редком событии, для которого он и предназначен.
О размере списка. Экран 16x2 показывает две строки — заголовок и активный пункт, поэтому комфортный список — три-пять позиций; дальше нужна прокрутка или страницы, а это уже другой уровень интерфейса. Арифметика листания строится на остатке от деления: вперёд — (menuIndex + 1) % N, назад — (menuIndex + N - 1) % N, где N — число пунктов. Обе формулы не выходят за границы ни при каком числе нажатий, и отдельные проверки if (menuIndex < 0) не нужны в принципе. Для int при трёх пунктах запас выглядит смешно, но привычка считать всё через % спасает, когда список вырастает до десяти.
#include <Wire.h>#include <LiquidCrystal_I2C.h>#include <IRremote.hpp> LiquidCrystal_I2C lcd(0x27, 16, 2); const int IR_PIN = 2;const int LED_PIN = 9; // ночник: на Uno ШИМ только вне 3 и 11const int PUMP_PIN = 10; // насос через MOSFET или релеconst int BUZZ_PIN = 8; // зуммер сигнализации String names[3] = {"Night", "Alarm", "Pump"};int state = 0; // 0 - NAV, 1 - RUNint menuIndex = 0;unsigned long lastBeep = 0; void drawMenu() { lcd.clear(); lcd.setCursor(0, 0); lcd.print("Menu:"); lcd.setCursor(0, 1); lcd.print(">"); lcd.print(names[menuIndex]); lcd.print(" "); // хвост затирает длинные имена} void stopAll() { analogWrite(LED_PIN, 0); analogWrite(PUMP_PIN, 0); noTone(BUZZ_PIN);} void setup() { Serial.begin(9600); IrReceiver.begin(IR_PIN, ENABLE_LED_FEEDBACK); lcd.init(); lcd.backlight(); pinMode(LED_PIN, OUTPUT); pinMode(PUMP_PIN, OUTPUT); drawMenu();} void loop() { if (IrReceiver.decode()) { int cmd = IrReceiver.decodedIRData.command; if (state == 0) { // NAV: листаем и выбираем if (cmd == 69) { menuIndex = (menuIndex + 1) % 3; drawMenu(); } if (cmd == 70) { menuIndex = (menuIndex + 2) % 3; drawMenu(); } if (cmd == 67) { // OK: применяем выбранный режим state = 1; lcd.clear(); lcd.setCursor(0, 0); lcd.print("Rabotaet:"); lcd.setCursor(0, 1); lcd.print(names[menuIndex]); lcd.print(" "); if (menuIndex == 0) analogWrite(LED_PIN, 90); if (menuIndex == 2) analogWrite(PUMP_PIN, 180); Serial.print("run="); Serial.println(menuIndex); } } else { // RUN: работает прибор if (cmd == 9) { // EQ: назад в меню state = 0; stopAll(); drawMenu(); } if (cmd == 67 && menuIndex == 1) { tone(BUZZ_PIN, 1500, 150); // тестовый пик сигнализации } } IrReceiver.resume(); } // сигнализация: короткий пик каждую секунду, без delay if (state == 1 && menuIndex == 1 && millis() - lastBeep > 1000) { lastBeep = millis(); tone(BUZZ_PIN, 1500, 150); }}Почему OK стоит отдельно, а не «нажал CH+ — режим сразу включился»? Вопрос цены промаха. Ночник можно применять вслепую: ошибся пунктом — переключил ещё раз. Но насос, заливающий грядку, или сигнализация, визжащая в три ночи, требуют, чтобы между «я листаю список» и «я подтверждаю» было отдельное действие. Моё правило: у прибора с нагрузкой сильнее светодиода выбор всегда через OK; у безобидного ночника допускаю и мгновенное применение прямо по CH+ — экономит одно нажатие. Для тонкой настройки внутри режима заводят третье состояние EDIT: значения пункта лежат в параллельном массиве values[], VOL+ и VOL- крутят текущее с шагом по смыслу (для минут полива — пять, для чувствительности — единицы), OK сохраняет и возвращает в NAV. Автомат просто обрастает ещё одной веткой switch, и в этом его прелесть: четвёртый и пятый режимы добавляются строкой в массиве и парой строк в ветках.
Пины в скетче на плате: ночник на 9-м, насос на 10-м — оба вне пинов 3 и 11, которые IRremote отнимает под Таймер 2, и оба ШИМ-ные. В симуляторе таймеров нет, поэтому там раскладка другая — диод сидит на 12-м, мотор на 11-м; пины всегда смотрите в шапке конкретного скетча, а не в памяти.
| Кнопка | Код NEC | Роль в меню |
|---|---|---|
| CH+ | 69 | следующий пункт, при удержании листает сам с гистерезисом |
| CH- | 70 | предыдущий пункт: (menuIndex + 2) % 3 |
| OK | 67 | запустить выбранный режим (переход NAV в RUN) |
| EQ | 9 | выход из режима в меню, глушит всю нагрузку |
| VOL+ / VOL- | 21 / 7 | внутри EDIT: менять значение выбранного пункта |
Симулятор: прибор с тремя режимами
В симуляторе тот же автомат стоит на макетке: диод изображает ночник, зуммер — сигнализацию, моторчик — насос. Понажимайте CH+ и CH-, увидите, как стрелка ходит по трём пунктам, потом OK — и на экране строка Rabotaet, а нагрузка отзовётся: диод притушится до 90 из 255, зуммер запикает раз в секунду, мотор закрутится на 180 из 255. EQ гасит всё и возвращает список. Обратите внимание на Serial: команда run= печатается только в момент запуска режима — если монитор замолчал, автомат просто сидит в RUN и никого не трогает.
Для настоящей поливалки диод заменяется реле с насосом, а пункты меню — на четыре-пять; скетч при этом не меняется ни на строку, кроме массива names и веток применения. В этом и сила автомата: он не знает, что за нагрузка у вас за пином, он знает только, какой пункт выбран.
Отличие симулятора от реального пульта ровно одно: виджет шлёт одиночные кадры, автоповтора у него нет, поэтому список листается строго по одному пункту за клик. На настоящем пульте удержание CH+ запустит самостоятельное листание — повтор-кадры каждые 108 мс будут двигать menuIndex сами, и без гистерезиса вы пролетите весь список за треть секунды. А Serial в симуляции покажет команду run= один раз, в момент входа в режим: если монитор молчит, автомат сидит в RUN и нагрузку не трогает.
- Меню — конечный автомат: переменная state и switch по ней; одна команда значит разное в NAV и RUN
- CH+ и CH- двигают menuIndex по кругу через остаток от деления, OK применяет, EQ возвращает и глушит нагрузку
- У прибора с сильной нагрузкой выбор всегда через отдельное подтверждение OK — листание и запуск не смешиваются
- Имена пунктов лежат в массиве String параллельно индексам; добавить режим — значит дописать строку и ветку применения
- Удержание CH+ листает список само: повторы каждые 108 мс, замедленные гистерезисом до одной смены в 150–200 мс
- 1. Что в этом меню выполняет роль конечного автомата?
- 2. После нажатия CH+ список перескочил на два пункта сразу. Откуда?
- 3. Почему запуск режима повешен на OK, а не на сам CH+?
- 4. Прибор работает в режиме Alarm. Как вернуться к списку?
- 5. Как добавить четвёртый режим в это меню?
Пульт листает, прибор исполняет, экран показывает — половина пультового прибора готова. Не хватает ровно одного: видеть, что приёмник вообще жив, пока вы щёлкаете кнопками из другого конца комнаты. Этим и займётся следующий урок — покажем на том же LCD принятую кнопку её именем, добавим отладочный режим со счётчиком кадров и перестанем гадать, кто виноват: пульт, приёмник или скетч.
Частые вопросы по теме урока
- Что в этом меню выполняет роль конечного автомата?
- Автомат — это состояние плюс таблица переходов. Здесь состояние хранит state (0 — NAV, 1 — RUN), а switch решает, что значит каждая кнопка. Переменная state с двумя значениями и switch, выбирающий реакцию на команду по состоянию
- После нажатия CH+ список перескочил на два пункта сразу. Откуда?
- Первый кадр и повторы несут одну и ту же команду. Для меню это фича — приём со сдвигом не чаще раза в 150–200 мс делает листание ровным. Пришёл повтор-кадр NEC: удержание шлёт кадры каждые 108 мс, каждый сдвигает индекс
- Почему запуск режима повешен на OK, а не на сам CH+?
- У безобидного ночника можно применять режим прямо по CH+, но силовая нагрузка требует отдельного подтверждения: промах по пункту не должен заливать грядку. Листание и подтверждение стоит разделять, когда режим включает насос или сигнализацию
- Прибор работает в режиме Alarm. Как вернуться к списку?
- В RUN скетч слушает только EQ. Всё остальное игнорируется сознательно: случайный кадр от чужого пульта не должен выключить сигнализацию. Нажать EQ: состояние станет NAV, stopAll() погасит нагрузку
- Как добавить четвёртый режим в это меню?
- Вся арифметика меню построена на остатке от деления по длине списка: новый пункт — это строка в names и одна ветка, где он включает свою нагрузку. Заменить все % 3 на % 4 и дописать имя в массив и ветку применения