Порог инкубатора я первый месяц менял перешивкой: поправил константу, скомпилировал, залил, проверил — минута на круг, а если плату уже повесил в шкаф, то и все двадцать. Кнопки «плюс» и «минус» спасали, но интерфейс из двух кнопок не отвечал, на каком значении стоит, и не умел ничего, кроме шага влево-вправо. Когда рядом оказался кабель, воткнутый ради прошивки, стало смешно: у платы есть канал до терминала, а я строю пульт, у которого кончаются пальцы.
Слова устройство уже понимает: readStringUntil читает строку целиком, trim срезает невидимые хвосты, эхо в квадратных скобках показывает, что приехало. Но on и off — команды без аргументов: сказать «держи двести» таким словарём нельзя. Командный парсер вырезал параметр через indexOf и substring, а ответом договорились считать одну строку OK или ERR. Здесь этот каркас доводится до пульта настоящего устройства: ON, OFF, AUTO, SET 200 и STATUS, где у половины команд есть числовой аргумент.
Разберём три способа достать число: toInt на строке, atoi на C-строке и parseInt прямо из потока — у каждого свои условия и свои грабли. Поймаем ловушку режима «Both NL & CR»: возврат каретки прилипает к хвосту команды, а при разбиении по пробелу переезжает в аргумент, где atoi его прощает, а строгое сравнение — нет. Таблица команд станет договором: что отправляете, что делает плата и чем отвечает. В симуляторе — тот же инкубатор с нагревателем на диоде и ручкой-датчиком: команды едут через монитор порта.
Слово и число: раскладываем строку
Первый шаг всегда одинаковый: readStringUntil(10) забирает строку до перевода строки, trim срезает пробелы и возврат каретки — ставлю его безусловно, страховка за ноль строк. Дальше строка делится по первому пробелу: indexOf(" ") даёт позицию, substring(0, позиция) — слово команды, substring(позиция + 1) — аргумент. Минус один из indexOf означает, что пробела нет: это команда без аргумента, и это нормальный случай, а не сбой — ветки ON и OFF живут именно здесь.
Аргумент в число конвертируется тремя дорогами. toInt — метод String, короткий и симметричный: на мусоре молча вернёт ноль. atoi(arg.c_str()) — дедовский C-стиль: читает цифры до первого нецифрового символа и останавливается, поэтому хвост вроде возврата каретки ему безразличен — «200\r» превращается в честные 200. parseInt читает прямо из потока, пропуская нечисловые байты, — удобно, когда число приходит одиноко, без слов; но при пустом буфере он блокирует loop до секунды, как и readStringUntil без терминатора. В одном скетче держите одну дорогу: смешанные вызовы вырывают байты друг у друга, об этом предупреждал ещё урок про readStringUntil.
Мой выбор для пульта устройства — строка целиком плюс toInt или atoi: число в команде обычно одно, и тащить его из потока отдельно незачем. parseInt остаётся для протоколов без слов: платка-угадайка из урока про parseInt и числа жила именно на нём. Сравнение слов веду в верхнем регистре: терминал присылает команду такой, какую набрали, ON, On и on — три разных слова, и toUpperCase сразу после trim снимает вопрос регистров навсегда.
| Команда | Аргумент | Действие | Ответ в терминал |
|---|---|---|---|
| ON | нет | нагреватель включён принудительно | OK on |
| OFF | нет | всё выключено | OK off |
| AUTO | нет | регулятор: горит, пока датчик ниже порога | OK auto |
| SET | число 1-1023 | новый порог авторежима | OK set=200 или ERR set |
| STATUS | нет | режим, порог, текущее значение датчика | status rejim=2 porog=200 |
// Инкубатор с терминалом: слово + число. LED - нагреватель (D12),// A0 - условный датчик температуры. Команды: ON / OFF / AUTO / SET 200 / STATUS.const int GRELKA = 12;const int DATCHIK = A0; int rejim = 0; // 0 - OFF, 1 - ON (принудительно), 2 - AUTO по порогуint porog = 200; void vypolni(String slovo, String arg) { if (slovo == "ON") { rejim = 1; Serial.println("OK on"); return; } if (slovo == "OFF") { rejim = 0; Serial.println("OK off"); return; } if (slovo == "AUTO") { rejim = 2; Serial.println("OK auto"); return; } if (slovo == "STATUS") { Serial.print("status rejim="); Serial.print(rejim); Serial.print(" porog="); Serial.println(porog); return; } if (slovo == "SET") { int n = atoi(arg.c_str()); // C-стиль: до первого нецифрового, "200\r" -> 200 if (n < 1 || n > 1023) { Serial.println("ERR set: nuzhno chislo 1-1023, naprimer SET 200"); } else { porog = n; Serial.print("OK set="); Serial.println(porog); } return; } Serial.println("ERR: komandy ON / OFF / AUTO / SET n / STATUS");} void setup() { Serial.begin(9600); pinMode(GRELKA, OUTPUT); Serial.println("Inkubator gotov: ON / OFF / AUTO / SET 200 / STATUS");} void loop() { int v = analogRead(DATCHIK); bool gretka = (rejim == 1) || (rejim == 2 && v < porog); digitalWrite(GRELKA, gretka ? HIGH : LOW); if (Serial.available() > 0) { String line = Serial.readStringUntil(10); line = line.trim(); // \r из режима NL+CR умирает здесь line = line.toUpperCase(); int probel = line.indexOf(' '); String slovo = (probel < 0) ? line : line.substring(0, probel); String arg = (probel < 0) ? "" : line.substring(probel + 1); if (arg.length() > 0) arg = arg.trim(); // лишний пробел в "SET 200" vypolni(slovo, arg); }}Регулятор живёт одной строкой: нагреватель горит в ручном режиме, а в авторежиме — пока датчик ниже порога. Терминал меняет не свет, а переменные состояния rejim и porog, и loop сам разруливает, что с ними делать. STATUS отвечает одной строкой с режимом, порогом и текущим значением — без ответа терминал превращается в крик в темноте: команда могла доехать, могла нет, и вы тыкаете вторую. Договор «одна команда — один ответ» достался нам от командного парсера; как прокачать такой регулятор до плавной мощности, показано в уроке про ШИМ-регулятор по командам.
Ветка SET — самая требовательная: аргумент надо не только достать, но и проверить. Пустой аргумент, мусор вроде abc и честный ноль после конверсии неразличимы — все дают ноль, и порог молча обнулился бы. Поэтому проверка диапазона 1-1023 и ERR с примером правильной команды: ответ «ERR set: nuzhno chislo 1-1023» экономит минуту перечитывания справки. Потолок сверху берётся из физики датчика — 1023 у десятибитного АЦП; у своей шкалы будет свой потолок, вписывайте его в проверку.
Регистр и хвосты — две страховки по одной строке каждая. toUpperCase после trim делает команду регистронезависимой; trim до всего остального убивает и возврат каретки, и случайные пробелы по краям. Больше ничего от строки не ждите: буфер приёма на 64 байта, про который мы считали в уроке про буфер UART, примет и длинную простыню — её разрежет на куски, и парсер честно ответит ERR на обрубок. Команда — это слово и число, не абзац: два-четыре символа на слово, до четырёх цифр на аргумент.
Мусор в аргументе и ответ по договору
Мусор в аргументе ловится до конверсии: пустая строка после substring и набор букв дают одинаковый ноль, и порог молча стал бы нулевым. Проверка диапазона с ERR-ответом превращает тишину в разговор. Лишний пробел в «SET 200» после разбиения оставляет в аргументе ведущий пробел — trim аргумента перед toInt стоит одной строкой и закрывает вопрос. Все эти проверки вместе — пять строк, и каждая отвечает за реальную посылку, которую я однажды набирал второпях.
parseInt в этом скетче нет намеренно: он читает из потока и при пустом буфере ждёт свой таймаут в секунду — в loop с планировщиком такая пауза растянет всё. Если ваши команды — голые числа без слов, parseInt уместен, механику и цену таймаута разбирали в уроке про parseInt. Правило одно: строка читается из порта одним вызовом, а всё остальное — уже разбор готовой строки; два читателя в одном loop делят один буфер и вырывают байты друг у друга.
Когда команд становится больше пяти, лестница if превращается в словарь: таблица «команда — функция» и обход по ней, как в уроке про словарь set/get. Мой потолок для лестницы — пять-шесть веток, дальше путаюсь, какая ветка что отвечает. А если управлять надо не с ноутбука, а с телефона, парсер переезжает на HC-05 без правки: Bluetooth-модуль приносит те же строки по воздуху, и договор команд не меняется ни на символ.
Повторные посылки — норма терминала: я шлю команду второй раз, если ответа не дождался за секунду. Отсюда требование к скетчу — безвредность повтора: ON на уже включённом нагревателе ничего не меняет, SET того же значения пишет то же число, OFF гасит и так погашенное. Команд-инкрементов вида «ещё плюс десять» я в терминале избегаю: один повтор удвоит шаг, и устройство уедет мимо цели. Состояние задаётся абсолютом, а не разницей к текущему. У безвредности есть вторая сторона: терминал не гарантирует, что доехал именно ответ — команда могла доехать, а OK потеряться на полпути; повтор при этом безвреден, потому и безопасно тыкать второй раз.
Типаж команд держу в трёх формах: действие без аргумента, установка с числом и запрос. Захотелось четвёртой — команды с двумя аргументами — остановитесь и подумайте: чаще это две команды по очереди, и разбирать «SET 100 200» заметно муторнее, чем SET 100 и следом SET 200. Мой потолок — один аргумент на команду; всё сложнее уже не терминал, а протокол, и для него есть кадры с заголовком и контрольной суммой из соседних уроков раздела.
И последнее: терминал — интерфейс для человека, а не для другой платы. Человек прощает читаемые слова и задержку в полсекунды; машина — нет. Как только соседом становится вторая Arduino, текстовый пульт переезжает в двоичный протокол с кадром и CRC — мы собирали его в уроке про фреймы. Для своих же рук терминал остаётся самым быстрым инструментом: ни библиотек, ни прошивки второй стороны — только монитор порта и словарь из пяти команд.
- 1. indexOf(" ") вернул -1. Что это значит для парсера?
- 2. Монитор стоит в Both NL & CR, trim забыли. Что приедет после отправки ON?
- 3. arg = "abc". Что вернёт arg.toInt()?
- 4. Чем atoi("200\r") отличается от сравнения arg == "200"?
- 5. Почему parseInt нельзя звать в loop наравне с readStringUntil?
- Строка делится по первому пробелу: indexOf(" "), substring до и после — слово и аргумент
- Число достаётся одной дорогой: toInt, atoi(c_str) или parseInt из потока — не смешивать
- trim до всего: \r из режима NL+CR при разбиении уезжает в аргумент, где его простят не все
- Аргумент проверяется диапазоном: мусор и пустота дают ноль — ERR с примером вместо тишины
- Одна команда — один ответ: OK с фактом, ERR с диагнозом, STATUS по запросу
Частые вопросы по теме урока
- indexOf(" ") вернул -1. Что это значит для парсера?
- Минус один — «разделителя нет»: ON и OFF приходят без аргумента, и это нормальная ветка протокола, а не сбой. Пробела нет — команда без аргумента
- Монитор стоит в Both NL & CR, trim забыли. Что приедет после отправки ON?
- readStringUntil съедает перевод строки, а возврат каретки остаётся в хвосте: сравнение с эталоном идёт посимвольно и молча промахивается. Строка "ON\r" — сравнение с "ON" промахнётся
- arg = "abc". Что вернёт arg.toInt()?
- toInt и atoi на мусоре дают ноль без всяких признаков. Отсюда проверка диапазона: число вне 1-1023 — ERR с примером. 0 — и порог молча стал бы нулевым
- Чем atoi("200\r") отличается от сравнения arg == "200"?
- atoi читает цифры до первого нецифрового символа и прощает хвосты; строгое сравнение требует полного совпадения. trim до всего — надёжнее обоих. atoi остановится на \r и вернёт 200, а сравнение промахнётся
- Почему parseInt нельзя звать в loop наравне с readStringUntil?
- Строка читается одним вызовом, число достаётся из неё. Смешанные вызовы делят один приёмный буфер — разбор рассыпается на редких глюках. Два читателя делят один буфер и вырывают байты друг у друга