До этого месяца мой ночник управлялся тремя кнопками на макетке: ярче, темнее, дежурка. Работало — пока однажды вечером я не поймал себя на том, что жонглирую ими в темноте на ощупь, как цирковой артист булавами, и всё равно жму не ту. А на столе в это время лежал кабель, воткнутый в плату ради прошивки, — тот самый USB, через который Arduino и компьютер уже умеют переговариваться. Смешно: у платы есть канал связи с терминалом, а я строил пульт из кнопок, у которых кончаются пальцы.
Канал этот мы уже обжили: в первом уроке про монитор порта печатали строки, в уроке про светодиод из монитора включали свет словом on, а readStringUntil научился забирать из порта целую строку до перевода строки. Чего не хватало всем этим опытам — параметров и обратной связи. Команда on зажигала лампу только целиком: ни сделать «на треть», ни дежурный огонёк погасить отдельно, ни узнать, дошла ли команда вообще — терминал молчал, и я тыкал отправку второй раз на всякий случай.
В этом уроке собираю из знакомых деталей командный парсер — мини-интерпретатор: читает строку из порта, вырезает из неё команду и параметр, исполняет и отвечает в терминал одной честной строкой — OK с фактом или ERR с диагнозом. Лампа на пине 9 останется той же, только у неё появится словарь команд, а жонглёрские кнопки можно увольнять.
Тупик: лестница if по всей строке
Мой первый парсер был честным и наивным: читать строку и сравнивать её целиком, на каждый чих. if (s == "led on") — включить. else if (s == "led off") — выключить. Потом понадобилась тусклая яркость — родилась ветка led 1, следом led 2 и led 3. К десятой команде лестница из else if заняла полтора экрана, и каждая новая хотелка влезала в неё ещё одной ступенькой.
Гнилая часть была не в длине, а в комбинаторике. led умел on, off, 1, 2, 3 — и led on с led 1 делали одно и то же: два места в скетче, которые надо править синхронно, а я однажды поправил одно из них. Дальше пришёл pwm — и под каждую яркость ветку не настрочишь. Тогда я ещё не понял главного: параметр — отдельная сущность, которую надо вырезать из строки, а не зашивать в сравнение. Команды умножаются на параметры, и лестница растёт как произведение, а не как сумма.
Сверху — два промаха. Первый: лестница молчала. Опечатка Led on с большой буквы проваливалась в конец и не вызывала ни ошибки, ни ответа: я не мог отличить «не понял команду» от «понял, но ничего не сделал». Второй — заманчивый ход «а возьму switch по строке»: не выйдет, switch в C++ сравнивает только с целыми константами. Мы это фиксировали в уроке про switch и режимы — там же осталась пометка «для строк — Serial-парсер». Вот парсер и будем писать.
const int PIN_LAMPA = 9;int pwm = 0; void setup() { Serial.begin(9600); pinMode(PIN_LAMPA, OUTPUT);} void loop() { if (Serial.available() > 0) { String s = Serial.readStringUntil(10); s.trim(); if (s == "led on") { pwm = 255; analogWrite(PIN_LAMPA, pwm); } else if (s == "led off") { pwm = 0; analogWrite(PIN_LAMPA, pwm); } else if (s == "led 1") { // дубль led on: правится синхронно с первой веткой pwm = 64; analogWrite(PIN_LAMPA, pwm); } else if (s == "led 2") { pwm = 128; analogWrite(PIN_LAMPA, pwm); } else if (s == "led 3") { pwm = 192; analogWrite(PIN_LAMPA, pwm); } // pwm 100? pwm 200? Под каждую яркость — новая ветка. // Led on с опечаткой падает в конец лестницы молча: // ни ошибки, ни ответа в терминал. }}Разрезать строку и закодировать команду
Взрослый парсер делает два шага, и оба собраны из знакомых деталей. Шаг первый — разборка строки. Границу между командой и параметром находит indexOf: cmd.indexOf(" ") возвращает позицию пробела, для строки pwm 128 это три. Слева от пробела — substring(0, probel), слово команды, справа — substring(probel + 1), параметр. Число из текста достаёт toInt(): строка 128 превращается в целое сто двадцать восемь. Честности ради: на нечисловой строке toInt не ругается, а молча отдаёт ноль, и скетч обязан сам решить, считать ли ноль ошибкой. Сосед по цеху — parseInt из урока про числа из порта: он делает то же, но читает прямо из порта и умеет ждать, а toInt работает по уже забранной строке мгновенно.
Шаг второй — кодирование. Строку switch всё равно не возьмёт, поэтому слово команды переводится в маленькое целое число: on — единица, off — двойка, pwm — тройка. Перевод живёт в одной функции на пять строк, kodKomandy(), и это единственное место скетча, где написаны строковые сравнения. Дальше — обычный switch (kod) с case 1, case 2, case 3 и default, как в уроке про режимы: коммутатор по номеру, ветвь на действие, break на выход.
Отсюда честный ответ на заголовок урока: switch по строкам из порта — это switch по коду команды, которому строку превратил переводчик. Цена обхода — один словарь: новая команда добавляется строкой в kodKomandy() и одним case в switch, лестница else if больше не размножается. Решение «что за команда» принимается в скетче ровно один раз — по-моему, это главная дисциплина, которую парсер наводит, и она ценнее экономии строк.
Парсер целиком: разборка, switch и ответы
Каркас цикла прежний: сначала Serial.available() — есть ли байты, потом readStringUntil(10) — забрать строку до перевода строки, код 10. Без проверки available читать нельзя: на пустом порту readStringUntil ждёт терминатор секунду и в реальной Arduino, и в нашем симуляторе, и loop встаёт в очередь — мы это ловили в уроке про чтение строк. Следом — trim(): монитор порта в настройке Both NL & CR присылает on с невидимым хвостом, и сравнение честно даёт ложь; trim срезает хвосты, и команда снова узнаётся.
Дальше разборка: пробел через indexOf(" "), слово и параметр через substring. У команд без параметра — on, off, help — параметр остаётся пустой строкой. У pwm параметр превращается в число toInt-ом и проверяется на диапазон: меньше нуля или больше 255 — не яркость, парсер отвечает ERR, называя цифру, которую получил. У led параметр — не число, а флаг: сравниваю его со строками 0 и 1, всё прочее — ERR led. И каждая ветвь switch заканчивается ответом: OK с фактом в формате имя=значение или ERR с тем, что пришло.
Формат ответа — половина протокола, поэтому отдельно. Ответы на команды — самые ценные строки журнала устройства: по ним видно, что терминал и плата договорились. Журнал мы уже приучали к порядку и меткам в уроке про уровни логов — ответы команд встают в тот же ряд без переделки. Пара имя=значение вида pwm=128 не случайна: плоттер симулятора и ваш график на компьютере строят ряд по такой паре, не спрашивая разрешения.
const int PIN_LAMPA = 9; // лампа с ШИМconst int PIN_DEZHURKA = 8; // дежурный огонёк int pwm = 0; int kodKomandy(String slovo) { if (slovo == "on") return 1; if (slovo == "off") return 2; if (slovo == "pwm") return 3; if (slovo == "led") return 4; if (slovo == "help") return 5; return 0;} void pecatHelp() { Serial.println("Komandy: on / off / pwm 0-255 / led 0-1 / help");} void setup() { Serial.begin(9600); pinMode(PIN_LAMPA, OUTPUT); pinMode(PIN_DEZHURKA, OUTPUT); pecatHelp();} void loop() { if (Serial.available() > 0) { String cmd = Serial.readStringUntil(10); cmd.trim(); int probel = cmd.indexOf(" "); String slovo = cmd; String param = ""; if (probel > 0) { slovo = cmd.substring(0, probel); param = cmd.substring(probel + 1); } switch (kodKomandy(slovo)) { case 1: pwm = 255; analogWrite(PIN_LAMPA, pwm); Serial.println("OK on: pwm=255"); break; case 2: pwm = 0; analogWrite(PIN_LAMPA, pwm); Serial.println("OK off: pwm=0"); break; case 3: { int yarkost = param.toInt(); if (yarkost >= 0 && yarkost <= 255) { pwm = yarkost; analogWrite(PIN_LAMPA, pwm); Serial.print("OK pwm: pwm="); Serial.println(pwm); } else { Serial.print("ERR diapazon 0-255: pwm="); Serial.println(yarkost); } break; } case 4: if (param == "1") { digitalWrite(PIN_DEZHURKA, HIGH); Serial.println("OK led: dezhurka=1"); } else if (param == "0") { digitalWrite(PIN_DEZHURKA, LOW); Serial.println("OK led: dezhurka=0"); } else { Serial.println("ERR led: nuzhno 0 ili 1"); } break; case 5: pecatHelp(); break; default: Serial.print("ERR: neizvestno: "); Serial.println(cmd); break; } }}| Команда | Параметр | Ответ устройства |
|---|---|---|
| on | нет | OK on: pwm=255 |
| off | нет | OK off: pwm=0 |
| pwm | число 0–255 | OK pwm: pwm=N; вне диапазона — ERR diapazon: pwm=N |
| led | 0 или 1 | OK led: dezhurka=0/1; прочее — ERR led: nuzhno 0 ili 1 |
| help | нет | перечень команд одной строкой |
| любое другое слово | — | ERR: neizvestno: и эхо того, что пришло |
Лампа под командой терминала: проверка в симуляторе
В симуляторе ниже — та же лампа на пине 9 с ШИМ и тот же парсер, только из словаря оставлены три команды: on, off и pwm. Печатайте их в строке ввода монитора и жмите «Отправить», окончание строки — «Новая строка», как и в прошлых уроках с вводом. Ответы идут парами имя=значение, поэтому плоттер рисует ряд pwm ступеньками: отправили pwm 64, потом pwm 128 — увидите две ступени, и они совпадут с полосой яркости на плате.
Числа по каналу: команда pwm 128 — семь символов, на 9600 бод это около семи миллисекунд приёма, ответ — ещё десяток-полтора. Пара десятков миллисекунд на команду — парсер успевает на ближайшем же обороте loop, пауза между командами может быть любой. На реальную плату полный скетч переносится как есть: лампа с резистором 220 Ом на пин 9, дежурный огонёк на пин 8 — и терминал становится пультом, у которого кнопки не кончаются. Куда дальше: этот же словарь примет команды не только с USB — по плану впереди SoftwareSerial и Bluetooth HC-05, и тогда лампой начнёт командовать телефон без всякого компьютера.
- Командный парсер — два шага: разрезать строку на слово и параметр (indexOf, substring, toInt) и перевести слово в числовой код для switch
- switch по String в C++ не живёт: строка превращается в код в одном месте — kodKomandy(); новая команда = строка там плюс один case
- Команда с параметром — одна ветвь: pwm проверяет диапазон 0–255, led — флаг 0/1; параметр вырезается, а не зашивается в сравнение
- На каждую команду — одна строка ответа: OK с фактом или ERR с тем, что пришло; молчащее устройство отправитель отличить от сломанного не может
- Числа в ответах печатайте парами имя=значение (pwm=128): читается в терминале и сразу строится рядом в плоттере
- 1. Почему нельзя написать switch (cmd), если cmd — строка-команда из порта?
- 2. В cmd лежит pwm 128. Что окажется в param после int probel = cmd.indexOf(" ") и param = cmd.substring(probel + 1)?
- 3. Что вернёт toInt() у строки, где вместо числа буквы, например abc?
- 4. Вы отправили pwm 300. Какой ответ делает команду различимой в журнале?
Частые вопросы по теме урока
- Почему нельзя написать switch (cmd), если cmd — строка-команда из порта?
- Сравнение в switch идёт только с целыми константами, это устройство языка. Отсюда приём урока: строка один раз превращается в код команды, и switch разбирает уже код. Метки case в C++ — только целые константы; строку сначала переводят в код, например в kodKomandy()
- В cmd лежит pwm 128. Что окажется в param после int probel = cmd.indexOf(" ") и param = cmd.substring(probel + 1)?
- Пробел на позиции 3, substring(4) забирает всё после него. Левая часть режется симметрично: substring(0, probel). 128
- Что вернёт toInt() у строки, где вместо числа буквы, например abc?
- toInt молча возвращает ноль. Поэтому pwm без числа превращается в pwm=0: лампа гаснет по честному ответу, а не по ошибке, — если вы не проверили параметр сами. Ноль: нечисловая строка честно даёт 0, проверять должен скетч
- Вы отправили pwm 300. Какой ответ делает команду различимой в журнале?
- ERR с эхом полученного значения закрывает тишину: отправитель видит и факт отклонения, и цифру, которая пришла. Приведение к максимуму и молчание оставляют его в неведении. ERR diapazon: pwm=300 — видно, что команда дошла, но отклонена, и почему