Ночник у кровати я собирался для себя любимого: сонный тёплый свет по команде, никаких выключателей в темноте. Схема была готова за вечер, а вот интерфейс — где я увяз. Первая версия слушала цифры: 1 — свет, 0 — тьма. Через неделю я просыпался, щурился на монитор и пытался вспомнить, что там было двойкой: яркость? ночной режим? Числа — язык для машины, а в три часа ночи хочется сказать устройству человеческое «on». Вариант с parseInt() отлетел сразу: наберёшь «on» — он молча вернёт ноль, потому что умеет читать только цифры, и то с оговорками. Мне нужен был способ принять строку целиком и сравнить её со словом-эталоном.
Такой способ называется readStringUntil(): функция читает буфер порта до символа-разделителя и возвращает готовую строку. Разберём её по винтикам: что происходит с терминатором, зачем нужен таймаут 1000 мс и почему в нашем симуляторе он работает точно как на плате. Поймаем классические грабли с невидимыми символами — случай «команда правильная, а не срабатывает» — и соберём протокол из трёх команд: on, off и status, с эхом всего, что пришло. Побайтовая копилка из урока про Serial.available и Serial.read после этого урока останется для особых случаев, а пульт из урока про управление светодиодом получит вторую жизнь: ночник вместо мигалки.
readStringUntil: строка до разделителя, и точка
Механика в одну фразу: Serial.readStringUntil('\n') вынимает из буфера символы, пока не встретит разделитель, и возвращает накопленное строкой типа String. Разделитель — терминатор — съедается сам: в буфере от него не остаётся хвоста, и следующий вызов начнётся с чистого листа. В этом весь договор «одна отправка пользователя — одна команда». Терминатор обычно пишут '\n' — перевод строки; тот же вызов с кодом символа, Serial.readStringUntil(10), вернёт ровно тот же результат: это одна команда в разных одеждах. Если разделитель так и не пришёл, функция не виснет вечно: она ждёт до таймаута — 1000 мс по умолчанию — и возвращает то, что успела накопить, хоть пустую строку. В симуляторе таймаут работает как на настоящей плате: отправьте строку без перевода строки — и loop() замрёт на секунду, я проверял.
Практическое следствие таймаута — симптом, который стоит узнавать в лицо: пульт «задумчивый», каждая команда срабатывает с задержкой в секунду. Значит, монитор порта стоит в режиме «Без знака окончания»: терминатора нет, и каждая команда оплачивается полной секундой ожидания. Лечение — выпадающий список внизу монитора, режим «Новая строка»; мы его настраивали ещё в уроке про первый разговор с платой. Вторая привычка — складывать результат в переменную: String cmd = Serial.readStringUntil('\n'); — и дальше работать с ней. Для одной-единственной команды сравнить можно прямо в if, без переменной. Но как только эталонов два и больше, чтение делайте ровно одно: второй вызов readStringUntil() увидит пустой буфер и будет ждать свой полный таймаут — об этой ловушке ниже, у неё в моём журнале ошибок отдельная страница.
const int LED_PIN = 9;int svet = 0; // состояние — в переменнойString cmd = ""; void setup() { Serial.begin(9600); pinMode(LED_PIN, OUTPUT); Serial.println("Nocnik slushaet: on / off / status");} void loop() { if (Serial.available() > 0) { cmd = Serial.readStringUntil('\n'); // строка целиком, \n съеден cmd.trim(); // срезаем пробелы и \r Serial.print("Ehko: ["); // скобки делают невидимое видимым Serial.print(cmd); Serial.println("]"); if (cmd == "on") { svet = 1; digitalWrite(LED_PIN, HIGH); Serial.println("SVET: VKL"); } else if (cmd == "off") { svet = 0; digitalWrite(LED_PIN, LOW); Serial.println("SVET: VYKL"); } else if (cmd == "status") { Serial.print("SVET: "); if (svet == 1) { Serial.println("VKL"); } else { Serial.println("VYKL"); } } else if (cmd.length() > 0) { Serial.println("Ne ponyal. Komandy: on / off / status"); } } delay(20);}trim() и невидимые символы: почему команда не срабатывает
Классика жанра: буквы верные, а ветка молчит. Чаще всего виноват невидимый байт. Монитор порта умеет прикладывать к команде возврат каретки '\r' — режим «Both NL & CR» шлёт его перед переводом строки, и readStringUntil() возвращает «on\r». На экране всё красиво: on. В памяти — три символа. Сравнение с «on» идёт посимвольно, третий символ не сходится, свет не зажигается — и вы полвечера подозреваете плату, провод и карму. Лечение — cmd.trim(): метод срезает пробелы и невидимые символы по краям строки, '\r' исчезает, команда становится сама собой. Ставьте trim сразу после чтения, без исключений — страховка за ноль строк кода. И помните про регистр: «ON», «On» и «on» — три разные строки; на настоящей плате регистр можно приручить методом cmd.toLowerCase(), который меняет строку на месте.
Как сравнивать строку с эталоном? Вариантов три, и все честные. Первый — оператор == со строковым литералом: if (cmd == "on") — самое читаемое решение, и в симуляторе оно работает один в один. Второй — переменная типа String, в которую кладут результат чтения и прогоняют по цепочке else if: так растут протоколы из десятка команд. Третий — дедовский способ без String вовсе: посимвольная копилка из урока про Serial.available и Serial.read, где байты складываются в массив и сравниваются вручную; так делают в экономных прошивках, где каждый байт памяти на счету, но для ночника это спорт. Мой совет — не смешивайте стили в одном скетче: копилка и readStringUntil в одном loop() будут вырывать байты друг у друга, и вы получите редкие, неподдающиеся глюки, на которые я потратил один незабываемый вечер.
Отдельно про длину. Приёмный буфер UART на Uno — 64 байта, и команды должны укладываться в него с запасом: длинные простыни readStringUntil разрежет на куски, и сравнение уедет. Для слов on, off, status и десятка похожих это неактуально, но привычку держите: команда — это слово, а не абзац. Вторая привычка — эхо до сравнения: печатайте то, что получили, ещё до веток if. Сначала видишь [on\r], потом понимаешь причину — именно в таком порядке, а не наоборот. Без эха невидимый символ живёт в буфере, как в тёмной комнате: на ощупь. Эти две строчки — trim и эхо — съели у меня один вечер отладки, зато с тех пор ни одна команда не загадочна.
| Что вы отправили | Что вернёт readStringUntil('\n') | Сработает ли сравнение с «on» |
|---|---|---|
| on, режим «Новая строка» | on — чистая строка | Да: свет зажжётся |
| on, режим «Both NL & CR» | on\r — с невидимым довеском | Нет, пока не отработал trim() |
| on с пробелами по краям | « on » — пробелы внутри строки | Нет; trim() срезает края |
| ON большими буквами | ON — регистр сохранён | Нет: посимвольно не совпадает |
| пустой Enter | пустая строка | Нет; фильтр length() > 0 молчит |
const int LED_PIN = 9; void setup() { Serial.begin(9600); pinMode(LED_PIN, OUTPUT); Serial.println("Komandy: on / lyuboe drugoe = vyklyuchit");} void loop() { if (Serial.available() > 0) { if (Serial.readStringUntil('\n') == "on") { digitalWrite(LED_PIN, HIGH); Serial.println("SVET: VKL"); } else { digitalWrite(LED_PIN, LOW); Serial.println("SVET: VYKL"); } } delay(20);}Протокол из трёх команд: on, off, status и ответ платой
Теперь протокол. on — зажечь: svet = 1, digitalWrite(LED_PIN, HIGH), ответ «SVET: VKL». off — погасить, ответ «SVET: VYKL». status — спросить: плата печатает текущее состояние из переменной svet. Состояние я держу в переменной, а не вычитываю со своего же пина: digitalRead() на выходном пине ведёт себя по-разному на разных платах, а переменная честная и всегда под рукой. Ответ устройством — не вежливость, а чек: строка «SVET: VKL» в логе доказывает, что команда дошла, распозналась и исполнилась. Эхо со скобками — вторая ступень диагностики: Serial.print("Ehko: [") перед печатью команды — и невидимый '\r' или лишний пробел становится видимым: [on ] глаз ловит за долю секунды, а [on] — чисто.
И решение про ветку else — оно зависит от устройства. Для ночника у кровати нераспознанная команда не должна гасить свет: мусорная строка из соседней вкладки в три часа ночи — худший из способов выключить лампу, поэтому в полном скетче ветка «Ne ponyal» ничего не переключает. Для обогревателя или поливалки — ровно наоборот: всё непонятное ведёт к выключению, безопасное состояние по умолчанию. Один и тот же скелет кода, зеркальная логика — выбирайте по цене ошибки. Полный разбор пульта с веткой «не понял» уже лежит в уроке про управление светодиодом, а когда захочется видеть, что плата думает о своих командах, — в уроке про отладку через Serial. Дальше протоколы будут толстеть: команды с параметрами вида «dim 128» собираются из readStringUntil и parseInt — из этого урока и урока про числа из монитора. А пока соберите ночник и покомандуйте ему словами: on, off, status.
И последнее — документируйте протокол. Через месяц вы не вспомните, что ночью называли двойкой яркость, а через два — какие команды вообще умеет плата. У меня шпаргалка живёт в комментарии к setup(): три строки, по одной на команду, с ответом платы. В воспитательных целях добавьте команду help: она печатает список того, что устройство понимает, и ответ платы сам становится документацией. Три команды — уже протокол, десять — интерфейс, и относиться к нему стоит с тем же уважением, что к схеме: менять осмысленно, а не по живому. Скопируйте ночник из этого урока — он станет каркасом: светодиод замените на реле, и «умная» розетка, командуемая словами из монитора, готова.
- readStringUntil('\n') читает буфер до разделителя, съедает сам разделитель и возвращает String; код 10 — то же самое
- Без терминатора функция ждёт до таймаута 1000 мс (и в симуляторе тоже) и возвращает накопленное — настройте монитор на «Новая строка»
- cmd.trim() срезает пробелы и невидимые '\r' по краям — ставьте сразу после чтения
- Для двух и более эталонов читайте буфер ровно один раз в переменную: второй вызов readStringUntil() промолчит полный таймаут
- Команда без ответа — не команда: печатайте эхо и подтверждение, а состояние устройства держите в переменной
- 1. Что вернёт Serial.readStringUntil('\n'), если терминатор за 1000 мс так и не пришёл?
- 2. Монитор стоит в режиме «Both NL & CR». Что вернёт readStringUntil('\n') для команды on?
- 3. Почему два вызова readStringUntil() подряд в двух сравнениях одного if — плохая идея?
- 4. Как безопаснее устроить ветку «всё нераспознанное» для обогревателя?
Частые вопросы по теме урока
- Что вернёт Serial.readStringUntil('\n'), если терминатор за 1000 мс так и не пришёл?
- Функция ждёт до таймаута, затем возвращает накопленное. Отсюда симптом «задумчивого пульта», если монитор не шлёт перевод строки. То, что успело накопиться, — возможно, пустую строку
- Монитор стоит в режиме «Both NL & CR». Что вернёт readStringUntil('\n') для команды on?
- Возврат каретки \r приклеивается перед переводом строки и остаётся в результате. Глаз видит «on», сравнение проваливается — лечит cmd.trim() сразу после чтения. on\r — строку с невидимым довеском
- Почему два вызова readStringUntil() подряд в двух сравнениях одного if — плохая идея?
- Первый вызов вычерпывает буфер вместе с терминатором. Читайте один раз в переменную и гоняйте её по цепочке else if. Второй вызов увидит пустой буфер и молча прождёт полный таймаут
- Как безопаснее устроить ветку «всё нераспознанное» для обогревателя?
- Цена ошибки решает: для ночника мусор не должен гасить свет, для обогревателя — любое непонятное означает выключение. Это паттерн «безопасное состояние по умолчанию». Выключать: всё непонятное ведёт к безопасному состоянию