Гирлянда на балконе слушалась меня ровно до первого дождя. Ну, не совсем так: слушалась она всегда, но не меня. Я обновил скетч, в котором команды принимались по-новому — строкой целиком, — а сам продолжал печатать по старой привычке большими буквами: ON, OFF. Свет включался через раз, гирлянда то гасла, то нет, и два вечера я честно подозревал окислившийся контакт: прозвонил мультиметром провода, переткнул разъёмы, поджал клеммы. Виноват был не контакт, а сравнение строк: эталон «on» и моя команда «ON» — с точки зрения C++ два разных слова, посимвольно не совпадающие ни в одном бите. Тогда же я заметил в логе загадочные «команда пустая» через каждые несколько секунд: это двойные нажатия Enter отправляли на плату пустые строки, и наивный код честно рапортовал о каждой. С тех пор я не отлаживаю железо, пока не отладил обмен: печатаю, что получила плата — и половина мистики испаряется.
Соберём пульт по шагам. Сначала readStringUntil('\n') — функция, которая читает строку целиком до перевода строки и решает главную проблему прошлого урока: не надо копить байты в копилку вручную. Затем сравнение с эталоном и ветка else — плата, которая умеет сказать «не поняла», экономит часы отладки. Отдельно пройдём по ловушкам: регистр букв, лишний хвост строки и пустые команды-призраки. Финал — рабочий выключатель: светодиод на пине 9 зажигается и гасится из монитора порта. Если вы пропустили урок про первое знакомство с Serial Monitor, вернитесь к нему за настройкой скорости и знака окончания строки — здесь эти грабли лежат прямо на дороге.
readStringUntil: строка целиком вместо копилки
В уроке про Serial.available и Serial.read мы собирали команду посимвольно: читали байт, проверяли, не перевод ли это строки, доклеивали к копилке. Работает, но кода много, и каждая строка кода — место, где можно ошибиться. Serial.readStringUntil() делает всё это за один вызов: читает буфер символ за символом до указанного терминатора и возвращает накопленное строкой. Терминатором обычно служит перевод строки: Serial.readStringUntil('\n') — или, что то же самое, код символа 10. Важная деталь, которую легко пропустить: терминатор читается из буфера и на этом вызов заканчивается — то есть функция съедает «\n» сама, вам не нужно ничего дочищать. Если же перевод строки так и не пришёл, функция прождёт до таймаута (те же 1000 мс по умолчанию, настраиваются Serial.setTimeout) и вернёт то, что успела накопить, — например, пустую строку.
Почему так принципиально, что «\n» съедается? Представьте обратное: символ остался в буфере. Следующий проход loop() проверяет available() — «данные есть!» — читает строку из одного невидимого символа, сравнивает с «on» — не совпало, печатает ошибку. И так после каждой команды, по лишней строке мусора в логе. Съеденный терминатор — это договор: одна отправка пользователя равна одной команде. В скетчах для настоящей платы результат удобно сразу положить в переменную: String cmd = Serial.readStringUntil('\n'); — и дальше работать с ней. В симуляторе ниже мы сравним строку с эталоном прямо в условии — для учебного скетча из одной команды этого достаточно, а полный вариант с переменной и тремя ветками я дам кодом для реальной платы.
Сравнение с эталоном и ветка «не поняла»
Сравнение строк в C++ честное и беспощадное: оператор == сопоставляет строки посимвольно, и любое расхождение проваливает всю проверку. «on», «On» и «ON» — три разных строки, и совпадёт с эталоном только одна. Хуже того: буква «о», набранная в русской раскладке, и латинская «o» выглядят одинаково, но коды у них разные, — глазом это не отличить, а сравнение увидит сразу. Отсюда правило гигиены: эталоны в скетче пишутся в одном регистре, маленькими буквами, и команды вы отправляете ровно в таком же виде. На настоящей плате регистр можно приручить программно: метод cmd.toLowerCase() приведёт строку к нижнему регистру перед сравнением, и тогда «OFF» сработает как «off». В симуляторных примерах этого самоучителя приведение регистра не используется, поэтому живём в нижнем регистре и следим за раскладкой — это честная тренировка внимания, а не костыль.
Ветка else для нераспознанного — не украшение, а деталь, которая превращает отладку из гадания в чтение. Когда плата в ответ на любую ерунду печатает «Ne ponyal: OFF», вы мгновенно видите, что пришло на самом деле: лишний пробел перед командой, русская буква, случайная цифра от соседней вкладки. Код приёма в уроке про побайтовое чтение уже делал так — и здесь тот же принцип. Одна оговорка: перед тем как ругаться, стоит проверить, что команда вообще была. Пустая строка (двойной Enter, случайное нажатие, лишний перевод строки) — не ошибка пользователя, и сыпать на неё «Ne ponyal» — значит замусорить лог, как в истории с моей гирляндой. Поэтому в полном скетче ветку «не поняла» защищает проверка cmd.length() > 0: пустышки молча пропускаются, а настоящие опечатки — печатаются целиком.
Собираем выключатель и придумываем, чем его кормить
Полный скетч для настоящей платы выглядит так: в setup() открываем порт, настраиваем пин 9 как выход и здороваемся; в loop() за забором из available() читаем строку до перевода строки, подрезаем края функцией trim() — она срезает пробелы и невидимый возврат каретки «\r», если монитор настроен слать Both NL & CR, — и прогоняем по цепочке сравнений. Совпало с «on» — digitalWrite(LED_PIN, HIGH), свет горит; совпало с «off» — LOW, погас; иначе, если строка была непустой, — печатаем, что получили. Обратите внимание, что сравнение с эталоном — это ровно одна строка на команду: добавление новых команд («blink», «status», «dim 128») не трогает существующие ветки, а дорисовывает новую.
В упрощённом скетче для симулятора ниже ветка одна: всё, что не «on», гасит светодиод. Это не лень, а рабочий паттерн «выключить по умолчанию» — так делают в устройствах, где любая ошибка канала должна приводить к безопасному состоянию: гирлянда гаснет, обогреватель выключается. Но как только команд становится две и больше, держите полный вариант с else if и сообщением об ошибке. Дальше это растёт естественным образом: вместо светодиода подключите реле — и та же пара команд управляет лампой на балконе или кормушкой; добавьте числа из предыдущего урока — и «dim 128» станет диммером. А когда команд наберётся с десяток, загляните в урок про форматированный вывод и отладку: там печать состояний поставлена на поток.
String cmd = "";const int LED_PIN = 9; void setup() { Serial.begin(9600); pinMode(LED_PIN, OUTPUT); Serial.println("Komandy: on / off");} void loop() { if (Serial.available() > 0) { cmd = Serial.readStringUntil('\n'); // строка целиком, \n съеден cmd.trim(); // срезаем пробелы и \r if (cmd == "on") { digitalWrite(LED_PIN, HIGH); Serial.println("LED: VKL"); } else if (cmd == "off") { digitalWrite(LED_PIN, LOW); Serial.println("LED: VYKL"); } else if (cmd.length() > 0) { Serial.print("Ne ponyal: "); // пустые строки молчим Serial.println(cmd); } } delay(20);}- Пинг-понг: пять раз подряд отправьте on и off по очереди. Светодиод обязан слушаться каждую команду — если пропустил, смотрите, что именно вы отправили.
- Проверьте регистр: отправьте OFF и On. Обе строки с эталоном «on» не совпадут, сработает else — свет погаснет «молча». Вот почему эталоны и команды живут в одном регистре.
- Отправьте пустую команду: просто Enter с пустым полем. Упрощённый скетч честно гасит свет — пустышка тоже «не on». В полной версии для платы ветка с length() > 0 пропустила бы её без ругани.
- Замените эталон «on» на «1» и отправляйте 1 и 0: цифры — тоже строки для сравнения, и компактные протоколы пультов так и устроены. Верните эталон обратно.
- Поменяйте ветки местами: в if поставьте сравнение с «off» и LOW, в else — HIGH. Отправьте on, off и ерунду вида kjk: теперь любая ошибка канала будет включать свет. Какой паттерн безопаснее для обогревателя?
- readStringUntil('\n') читает строку целиком до терминатора и съедает сам «\n» — копилка из прошлого урока больше не нужна.
- Без терминатора функция ждёт до таймаута (1000 мс по умолчанию) и возвращает накопленное — настройте монитор на «Новая строка».
- Сравнение строк посимвольное и с учётом регистра: on, On и OFF — разные строки; русская «о» и латинская «o» — разные байты.
- Ветка else с печатью полученного («Ne ponyal: ...») делает ошибки видимыми; проверка length() > 0 защищает лог от пустых команд-призраков.
- «Выключить по умолчанию» — рабочий паттерн: всё, что не «on», гасит нагрузку; для двух и более команд — полная цепочка if / else if / else.
- 1. Что делает readStringUntil('\n') с терминатором в конце команды?
- 2. Эталон в скетче — «off». Пользователь отправил «OFF». Что произойдёт?
- 3. Зачем в ветке else печатать не только «не понял», но и саму полученную строку?
- 4. В логе каждые несколько секунд появляется «Ne ponyal:» с пустой строкой после. Откуда пустые команды?
Пульт работает: строка из монитора, эталон, ответ — и светодиод подчиняется слову, а не цифре. Числа из предыдущего урока и команды из этого — два кирпича, из которых складывается любой протокол устройства: «dim 128», «set 24.5», «mode night». Логичный следующий шаг — текстовое меню в Serial, где плата сама печатает список режимов и перестраивает поведение по вашему выбору. А если пульт уже зовёт в настоящий проект, держите под рукой урок про отладку через Serial: печать состояний в нужных местах скажет вам о протоколе больше, чем любые догадки.
Частые вопросы по теме урока
- Что делает readStringUntil('\n') с терминатором в конце команды?
- Функция читает буфер до терминатора включительно и возвращает строку без него. Если бы «\n» оставался, каждый следующий проход разбирал бы пустую «команду» из невидимого символа. Читает вместе с ним — следующая команда начнётся с чистого буфера
- Эталон в скетче — «off». Пользователь отправил «OFF». Что произойдёт?
- Оператор == сопоставляет строки посимвольно и с учётом регистра. Чтобы «OFF» срабатывал, приведите строку к нижнему регистру через toLowerCase() или договоритесь писать команды одинаково. Сравнение провалится посимвольно — сработает ветка для нераспознанного
- Зачем в ветке else печатать не только «не понял», но и саму полученную строку?
- Эхо нераспознанного превращает отладку в чтение: видно и лишний пробел, и невидимый «\r», и путаницу регистров. Без эха вы будете гадать, что именно не совпало с эталоном. Чтобы сразу увидеть, что реально пришло: лишний пробел, русская буква или регистр
- В логе каждые несколько секунд появляется «Ne ponyal:» с пустой строкой после. Откуда пустые команды?
- Каждая пустая отправка — это просто «\n». Функция вернёт пустую строку, сравнения провалятся, и ветка ошибок рапортует о пустышке. Лечение — фильтр cmd.length() > 0 перед печатью. Лишние переводы строк (например, двойной Enter) долетают отдельными пустыми строками