На балконе у меня висит самодельная гирлянда-ночник: цепочка диодов на пине 9 и три сценария на вкус — ровная мигалка, плавное «дыхание» и морзянка SOS на аварийный случай. Всё было отлично ровно до вечера, когда захотелось сменить режим с дивана. Путь оказался неблизкий: ноутбук, кабель, компиляция, заливка, проверка — полминуты возни ради одной кнопки «переключить». А когда жена попросила «сделай, чтобы помедленнее мигало», выяснилось, что темп мигалки у меня зашит в константу, и вторая перепрошивка состоялась в тот же вечер. Прибор, у которого единственный пульт — это USB-кабель и среда программирования, прибором управлять неудобно, как бы честно он ни мигал.
Решение старо как мир: меню. Точно такое, какое вы видите в банкомате или в настройках роутера: плата печатает список пунктов, вы вводите номер, программа переключается на выбранный режим — и перепрошивка ради смены настроения больше не нужна. В этом уроке соберём меню из трёх пунктов поверх строковых команд из урока про readStringUntil: разберём, почему шпаргалка печатается в setup(), чем пункты-номера лучше пунктов-слов, как отвечать на неверный ввод и как сделать пункт, который задаёт встречный вопрос — например, просит ввести скорость мигания в миллисекундах. Вся практика — в симуляторе ниже: светодиод и монитор порта прилагаются.
Почему меню живёт в setup(), а не в loop()
Меню печатается один раз при старте, поэтому его место в setup(). setup() выполняется единожды после подачи питания или ресета, и список пунктов появляется в мониторе ровно один раз — аккуратной шпаргалкой. Если перенести печать в loop(), плата будет заливать монитор одним и тем же списком несколько раз в секунду: и ваши команды, и ответы платы утонут в повторах — примерно так портится лог, если печатать строки данных без паузы, о чём мы уже говорили в уроке про таблицу данных. loop() же занят делом: в начале прохода он коротким блоком проверяет, не пришёл ли номер, а всё остальное время исполняет выбранный режим.
Номер активного пункта живёт в переменной rejim. Она объявлена на верхнем уровне скетча, а не внутри loop(), поэтому переживает каждый проход: пришла команда — меняем rejim, идёт очередной проход — исполняем то, что в rejim записано. Это разделение «ввод — в начале, исполнение — в конце» — не эстетика, а запас прочности: когда режимов станет пять, а пунктов десять, структура не поедет, и дописывать придётся только ветки.
У «напечатали и забыли» есть тёмная сторона, о которой вспоминаешь не сразу: плата стартует раньше, чем вы открываете монитор. Залили скетч, развернули окно порта через полминуты — шпаргалка уже уехала, и вы смотрите на пустой экран. Лекарство простое: держите в протоколе пункт «?», по которому плата печатает меню заново. Он же служит справкой: любая непонятная команда получает в ответ краткую подсказку с допустимыми номерами, а не молчание. Плата, которая отвечает документацией, экономит вам открытие вкладки с исходником — проверено на себе.
Номер против команды: считаем цифрами
У пультов через порт два языка: слова и номера. Слова мы уже обкатывали — on, off, status из урока про строковые команды читаются мгновенно и не требуют объяснений в комментариях. Но у слов есть предел масштаба: для трёх команд латиница не пугает, а для десяти режимов названия либо уезжают в аббревиатуры, либо превращаются в квест «как я назвал режим с цветомузыкой — svet или disco?» Номера — язык меню: одна клавиша, никаких раскладок, регистров и опечаток в длинных словах, а новый пункт стоит одну строку println. Поэтому банкоматы, роутеры и моя гирлянда считают цифрами.
Механика такая: строку читаем знакомым Serial.readStringUntil('\n'), а число из неё достаёт метод toInt() — мы впервые встретили его в уроке про чтение команд, где из копилки с набранными цифрами получалась яркость светодиода. toInt() читает цифры с начала строки и обрывается на первом нецифровом символе: «2» станет двойкой, и «2 kg» станет двойкой. Приятный бонус против строкового сравнения: хвостовой '\r' из режима «Both NL & CR» числу не страшен — он не цифра, набор на нём обрывается, и ответ верный. Помните, как «on\r» не совпадало с «on» и требовало trim()? Числа этот грабль объезжают стороной, и в скетче меню trim не нужен.
Теперь плохая новость, к которой лучше подготовиться заранее: если цифр в строке нет вообще, toInt() молча возвращает 0. «dva», случайный пробел, скопированная с кириллицей «о» — всё превратится в честный ноль без единого предупреждения; та же беззвучность была у parseInt() в уроке про числа. Пункта «0» в моём меню не предусмотрено, поэтому мусор и честный ноль уходят в одну ветку с подсказкой — я не пытаюсь их различать, потому что и то и другое лечится одним ответом: «выберите номер». Отдельно караулит Enter без символов: пустая строка тоже даст 0, но length() подскажет, что пришло именно ничего, — такие отправки я пропускаю молча, чтобы случайный Enter не сыпал подсказками в монитор.
| Что вы отправили | Что вернёт toInt() | Как отзовётся плата |
|---|---|---|
| 1 | 1 | «Rejim: 1» — врубится мигалка |
| 2 | 2 | «Rejim: 2» — начнётся плавное дыхание |
| 7 | 7 — цифра честная, но пункта нет | «Net punkta 7. Est tolko 1, 2 i 3.» |
| dva | 0 — цифр нет, мусор даёт ноль | «Vvedite nomer 1, 2 ili 3» |
| пустой Enter | 0 и пустая строка | Молча: length() == 0, подсказку не спамим |
| 2 в режиме «Both NL & CR» | 2 — хвостовой \r не цифра | «Rejim: 2» — trim() не понадобился |
Три режима на одном пине: мигалка, дыхание и SOS
Исполнение — это лестница if / else if в конце loop(), которая смотрит в rejim и играет нужный сценарий. Мигалка — два digitalWrite с delay(300). Дыхание — пара циклов for, где analogWrite гонит яркость от 0 до 255 и обратно с шагом 5: это тот же ШИМ из урока про плавную яркость, только вместо потенциометра ступеньки считает счётчик цикла. SOS — морзянка: три коротких вспышки, три длинных и снова три коротких. Все три сценария живут на одном пине 9, digitalWrite и analogWrite спокойно сменяют друг друга; при смене режима я гашу светодиод, чтобы дыхание всегда начиналось с темноты, а не с середины чужой вспышки.
Два штриха отличают живое меню от учебного. Первый — подтверждение «Rejim: N» после каждой смены: это чек о том, что команда доехала, распозналась и применилась; без него вы не отличите «принял» от «промолчал» — привычка из урока про отладку через Serial. Второй — ответ на неверный ввод. В прошлый раз ветка «не понял» обслуживала слова, и одного «Ne ponyal» хватало с головой. Числа позволяют ответить конкретнее: «Net punkta 7. Est tolko 1, 2 i 3.» — видно и что именно пришло, и в каких пределах выбирать. И запаситесь терпением: команда сработает не мгновенно, а после конца текущего такта — delay() держит паузу до конца, а номер тем временем дожидается в приёмном буфере. О том, какой у этого ожидания потолок и что бывает, когда он кончается, — весь следующий урок.
const int LED_PIN = 9;int rejim = 1; // активный режим: 1, 2 или 3String cmd = ""; void setup() { Serial.begin(9600); pinMode(LED_PIN, OUTPUT); Serial.println("=== MENYU ==="); Serial.println("1 - migalka"); Serial.println("2 - dyhanie (plavnyj svet)"); Serial.println("3 - signala SOS"); Serial.println("Vvedite nomer punkta:");} void loop() { if (Serial.available() > 0) { cmd = Serial.readStringUntil('\n'); int vybor = cmd.toInt(); // цифры с начала строки; мусор даст 0 if (vybor >= 1 && vybor <= 3) { rejim = vybor; digitalWrite(LED_PIN, LOW); // новый режим начнём с темноты Serial.print("Rejim: "); Serial.println(rejim); } else if (vybor > 3) { Serial.print("Net punkta "); Serial.print(vybor); Serial.println(". Est tolko 1, 2 i 3."); } else if (cmd.length() > 0) { Serial.println("Vvedite nomer 1, 2 ili 3"); } } if (rejim == 1) { digitalWrite(LED_PIN, HIGH); delay(300); digitalWrite(LED_PIN, LOW); delay(300); } else if (rejim == 2) { for (int y = 0; y <= 255; y += 5) { analogWrite(LED_PIN, y); delay(15); } for (int y = 255; y >= 0; y -= 5) { analogWrite(LED_PIN, y); delay(15); } } else { for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(150); digitalWrite(LED_PIN, LOW); delay(150); } for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(500); digitalWrite(LED_PIN, LOW); delay(150); } for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(150); digitalWrite(LED_PIN, LOW); delay(400); } }}Меню с параметрами: пункт, который задаёт вопрос
Через неделю жизни с меню выяснилось, что трёх пунктов мало — не по числу режимов, а по настройкам: «помедленнее мигало» вернулось как пожелание, которое номером не выражается. Выход — пункт-вопрос: выбираешь «4 — скорость мигалки», и плата в ответ не переключает режим, а задаёт встречный вопрос: «Pauza v ms (50-3000):». Следующий ввод она трактует уже не как номер пункта, а как значение параметра. В коде это флаг zhdyomChislo: ноль — меню ждёт пункт, единица — меню ждёт число. Двухшаговый диалог — простейшая форма меню второго уровня, и для подавляющего большинства настроек устройства его хватает с запасом.
Границы диапазона здесь работают на два фронта. Сверху 3000 мс — чтобы слишком щедрая пауза не выглядела зависанием прибора. Снизу 50 — и это хитрее, чем кажется: глаз не отличит вспышку короче пятидесяти миллисекунд, а заодно диапазон защищает от классической путаницы двухступенчатого меню. Если вы в режиме ожидания параметра по ошибке отправите «2», скетч не примет её ни как паузу (меньше 50), ни как режим: ответит «Nuzhno chislo 50-3000» и попросит число заново. Заметьте и тон: подсказка называет границы прямо. Это развитие ветки «не понял» из строкового урока — там мы говорили «Ne ponyal», здесь ответ уточняет, что именно ожидалось и в каких пределах.
const int LED_PIN = 9;int rejim = 1;int pauza = 300; // темп мигалки, мсint zhdyomChislo = 0; // 1 — меню ждёт параметр после пункта 4String cmd = ""; void setup() { Serial.begin(9600); pinMode(LED_PIN, OUTPUT); Serial.println("=== MENYU ==="); Serial.println("1 - migalka"); Serial.println("2 - dyhanie (plavnyj svet)"); Serial.println("3 - signala SOS"); Serial.println("4 - skorost migalki"); Serial.println("Vvedite nomer punkta:");} void loop() { if (Serial.available() > 0) { cmd = Serial.readStringUntil('\n'); int vybor = cmd.toInt(); if (zhdyomChislo == 1) { // прошлым пунктом выбрали «4» if (vybor >= 50 && vybor <= 3000) { pauza = vybor; Serial.print("Skorost: "); Serial.println(pauza); } else { Serial.println("Nuzhno chislo 50-3000"); } zhdyomChislo = 0; } else if (vybor == 4) { Serial.println("Pauza v ms (50-3000):"); zhdyomChislo = 1; } else if (vybor == 1 || vybor == 2 || vybor == 3) { rejim = vybor; digitalWrite(LED_PIN, LOW); Serial.print("Rejim: "); Serial.println(rejim); } else if (cmd.length() > 0) { Serial.println("Punkty 1-4. Vyberite chislom."); } } if (rejim == 1) { digitalWrite(LED_PIN, HIGH); delay(pauza); digitalWrite(LED_PIN, LOW); delay(pauza); } else if (rejim == 2) { for (int y = 0; y <= 255; y += 5) { analogWrite(LED_PIN, y); delay(15); } for (int y = 255; y >= 0; y -= 5) { analogWrite(LED_PIN, y); delay(15); } } else { for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(150); digitalWrite(LED_PIN, LOW); delay(150); } for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(500); digitalWrite(LED_PIN, LOW); delay(150); } for (int i = 0; i < 3; i++) { digitalWrite(LED_PIN, HIGH); delay(150); digitalWrite(LED_PIN, LOW); delay(400); } }}- Меню — шпаргалка, напечатанная один раз в setup(): loop() занят исполнением режимов и не должен заливать монитор повторами
- Выбор читают строкой readStringUntil('\n') и переводят в число cmd.toInt(): цифры с начала строки, мусор молча даёт 0
- Пункты-номера масштабируются лучше слов: одна клавиша, без раскладки и регистра; хвостовой \r числу не помеха
- Активный режим живёт в переменной и исполняется лестницей if/else if в конце loop(); подтверждение «Rejim: N» — чек о доставке
- Пункт-вопрос с флагом-ожиданием превращает меню в двухшаговый диалог; диапазон параметра отсекает и бессмысленные значения, и случайные цифры-режимы
- 1. Почему список пунктов меню печатают в setup(), а не в loop()?
- 2. Что вернёт cmd.toInt() для строки «dva»?
- 3. Пользователь отправил «7» в меню из трёх пунктов. Какая ветка ответит лучше всего?
- 4. В меню с пунктом-вопросом «4 — скорость» человек в режиме ожидания параметра отправил «2». Почему пауза не станет равной 2 мс?
Частые вопросы по теме урока
- Почему список пунктов меню печатают в setup(), а не в loop()?
- setup() выполняется один раз за включение — список появляется ровно один раз. Печать в loop() заливала бы монитор повторами несколько раз в секунду. loop() занят исполнением режимов, а шпаргалка нужна один раз при старте
- Что вернёт cmd.toInt() для строки «dva»?
- toInt() читает цифры с начала строки; если их нет — тихо возвращает 0. Поэтому «мусор» и честный ноль различают ответом платы, а не кодом ошибки. 0 — нечисловая строка молча даёт ноль
- Пользователь отправил «7» в меню из трёх пунктов. Какая ветка ответит лучше всего?
- Для чисел подсказка конкретнее «Ne ponyal»: видно, что именно пришло и в каких пределах выбирать. А вот полный ребус меню на каждый промах — уже спам. Эхо номера и допустимый диапазон: «Net punkta 7. Est tolko 1, 2 i 3.»
- В меню с пунктом-вопросом «4 — скорость» человек в режиме ожидания параметра отправил «2». Почему пауза не станет равной 2 мс?
- Нижняя граница в 50 мс отбрасывает и случайные «2», и «1» с «3»: скетч попросит число заново вместо того, чтобы принять невозможную паузу. Диапазон параметра 50–3000 отсекает цифры-режимы