Паяльник в правой руке, лампа на кронштейне в левой — и нужна третья, чтобы напечатать в монитор порта слово on целиком. Через несколько таких сессий мои команды съёжились до одной буквы: o — включить, f — выключить, m — маяк. И тут вскрылось, что пульт на строках из урока про управление светодиодом из Serial для однобуквенных команд избыточен: readStringUntil копит строку, подрезает края, сравнивает целиком — а я шлю один байт. Одному байту нужны однобайтовые замки.
Пульт собирается из двух уже знакомых деталей. Коммутатор switch из урока про режимы умеет метки-символы: 'o' — это просто число-код, и case 'o': такой же законный, как case 2:. Посимвольный приём — Serial.read из урока про available и read. Нового здесь два: фильтр переводов строки до switch и команды с параметром — «b 500» — где букву читает read, а число добирает parseInt.
К финалу будет консоль из четырёх команд с ответами платы, таблица «напечатал — сделано — получено в ответ» и три грабли монитора порта, каждая из которых стоит вечера, если встретить её без предупреждения.
Одна буква — одна команда
Символ в C++ — маленькое целое: 'o' — это код 111, 'f' — 102, и switch по символу делает тот же прыжок на метку, что switch по числам. Отсюда главное отличие от строкового пульта: Serial.read() отдаёт один байт, switch(c) одним прыжком попадает в ветвь, и никаких сравнений строк целиком. Десяток команд влезает в один экран, каждая ветвь — два-три вызова, и лестницы if/else с прилипающими else не заводится в принципе.
Бонус из прошлого урока здесь работает на регистр: case 'o': case 'O': — две метки подряд над одним телом, и сосед, печатающий большими буквами, остаётся в игре. То же на цифрах: case '1': case '2': case '3': — быстрый выбор из списка, если однобуквенных имён не хватает. А вот кириллическая тёзка латинской буквы кодом не совпадёт никогда — это к граблям, они ниже.
Но сначала фильтр. Монитор порта к каждой посылке приклеивает перевод строки, а при настройке Both NL & CR — ещё и возврат каретки: это байты с кодами 10 и 13. Без фильтра каждая команда приезжает с двумя пустышками вдогонку, и default сыплет в лог мусор. Одна строка if отсеивает служебные байты до switch — и пульт перестаёт отвечать на пустоту. Это же место — точка входа для своих служебных символов, если приручите другой терминал: расширяется фильтр, а не switch.
const int LED = 12; int rezhim = 0; // 0 - выкл, 1 - ровный свет, 2 - маякint poluperiod = 300; // мс, меняет команда bunsigned long lastMig = 0;int gorim = 0; void mayak() { if (millis() - lastMig >= poluperiod) { lastMig = millis(); gorim = 1 - gorim; digitalWrite(LED, gorim); }} void setup() { pinMode(LED, OUTPUT); Serial.begin(9600); Serial.println("Pult: o - svet, f - vykl, m - mayak, s - status");} void loop() { if (rezhim == 2) mayak(); if (Serial.available() > 0) { char c = Serial.read(); if (c != '\n' && c != '\r' && c != ' ') { switch (c) { case 'o': case 'O': rezhim = 1; digitalWrite(LED, HIGH); Serial.println("svet: vkl"); break; case 'f': case 'F': rezhim = 0; digitalWrite(LED, LOW); Serial.println("svet: vykl"); break; case 'm': case 'M': rezhim = 2; Serial.println("mayak: vkl"); break; case 's': Serial.print("rezhim="); Serial.println(rezhim); break; default: Serial.print("kod="); Serial.println(c); break; } } }}Смотрите на default: он печатает код непонятого байта, а не молчит. В симуляторе кириллическая буква доедет по одному коду на символ; на настоящей плате монитор шлёт UTF-8, и каждая русская буква приходит двумя байтами — у «о» это коды 208 и 190. Глазом кириллическую тёзку от латинской не отличить, а вот код в логе отвечает мгновенно: два числа вместо одного — значит, раскладка съехала. Молчаливый default превращает это в загадку, говорящий — в ответ на экране.
Статус по запросу — маленькая привычка с большим сроком службы. Команда s печатает текущий режим, и пульт сам становится отладчиком: когда команд станет десять, вы будете спрашивать плату «что у тебя?» не потому, что забыли, а чтобы сверить, дошла ли предыдущая посылка. Я вешаю такую команду на любой новый пульт первой — раньше, чем рабочие ветви. Сюда же примыкает эхо-ответ при смене параметра: строка poluperiod=500 подтверждает, что число дошло ровно такое, какое вы отправили, без обрезанных нулей и лишних пробелов.
Команда с числом: буква, потом parseInt
Рано или поздно команды перестают помещаться в буквы: частота маяка, яркость, номер пина. Универсальная раскладка — буква и число: «b 500» — маяк с полупериодом 500 мс. Читается она в два приёма: букву берёт Serial.read(), а число добирает Serial.parseInt() — правила разбора чисел мы уже раскладывали в уроке про parseInt, и здесь они играют на нашу сторону: parseInt пропускает всё нецифровое до первого числа, поэтому «b500» и «b 500» равнозначны, пробел можно не печатать.
Разбор останавливается на первом нецифровом символе после цифр — хвостовой перевод строки закрывает команду сам. Обратная сторона: если после 'b' числа так и не пришло, parseInt дождётся таймаута — 1000 мс по умолчанию, меняется Serial.setTimeout() — и вернёт 0. Ноль в роли полупериода — это маяк без пауз, то есть светодиод, который дёргается каждый проход loop; поэтому прочитанное число сразу зажимается constrain-ом, и пульт не может сломать себе скетч одной опечаткой.
Один параметр — и раскладка растёт сама: «d 128» — половина яркости, «b 100» — быстрый маяк, «p 7» — свет на седьмом пине. Это уже язык, а не список команд: буква — глагол, число — дополнение. Код приёма при этом не меняется ни на символ.
case 'b': { int pauza = Serial.parseInt(); pauza = constrain(pauza, 50, 5000); poluperiod = pauza; Serial.print("poluperiod="); Serial.println(poluperiod); break;}Скобки вокруг тела case 'b' — не украшение: внутри заводится int pauza, а объявление переменной внутри ветви switch в C++ требует собственного блока. Без скобок компилятор свяжет pauza со всей конструкцией switch и заупрямится — грабля мелкая, но на скетчах из сети узнаваемая. Пауза здесь — полупериод: маяк горит poluperiod миллисекунд и столько же темнит, значит полный период вдвое больше, и «b 100» — это пять миганий в секунду, бодро, но ещё читаемо глазом.
И про доставку посылки. Монитор отправляет строку одним куском, но loop вправе заглянуть в порт в середине передачи: read заберёт букву, а числа в буфере ещё не будет — тогда parseInt прождёт свой таймаут и вернёт ноль, зажатый constrain-ом до 50. На 9600 бод байт едет порядка миллисекунды, и пять символов посылки доезжают за пять, но loop успевает прокрутиться сотни раз за это время, так что разрезанная посылка — не теория, а будни. Пульт от этого не ломается: плата отвечает poluperiod=50, и вы видите, что посылка приехала половинами — просто отправьте ещё раз.
| Печатаем в монитор | Что делает плата | Что отвечает в лог |
|---|---|---|
| o или O | режим 1: ровный свет | svet: vkl |
| f или F | режим 0: гасит всё | svet: vykl |
| m | режим 2: маяк на millis | mayak: vkl |
| b 500 | полупериод маяка 500 мс, зажат 50..5000 | poluperiod=500 |
| s | ничего не меняет, печатает режим | rezhim=2 |
| кириллическая о | ничего не делает | kod=208 и kod=190 — два байта UTF-8 |
Куда расти. Когда команд наберётся с десяток, ветви распухнут — тогда каждая превращается в функцию-обработчика из урока про свои функции, и switch остаётся диспетчером: одна строка на команду, тела спрятаны. Вторая ступень — параметры: «d 128» для яркости — тот же read плюс parseInt, только результат уезжает в analogWrite из урока про ШИМ. И эхо-контроль: на каждое изменение параметра плата возвращает принятое число, и рассинхрон между отправленным и принятым ловится в ту же секунду, а не через неделю на странном поведении маяка.
И подумайте о порядке ответа. Плата, которая молчит после команды, — загадка; плата, которая печатает подтверждение на каждый байт, — болтун, засыпающий лог. Я держу правило: подтверждение — на команду, статус — по запросу, и никак иначе. На s в пульте выше это правило уже работает — проверьте его сами, отправив s три раза подряд и посчитав строки в логе.
- 1. Пользователь напечатал букву o. Что отдаст Serial.read()?
- 2. Зачем перед switch стоит фильтр if (c != '\n' && c != '\r' && c != ' ')?
- 3. Как одним телом ветви поймать и 'o', и 'O'?
- 4. Отправлено b 500. Что вернёт Serial.parseInt() сразу после того, как read забрал букву b?
- 5. Что вернёт parseInt, если после 'b' число так и не пришло?
- Символ — маленькое целое: switch(c) с метками 'o' и 'f' — тот же коммутатор, что по числам; Serial.read() отдаёт один байт на символ.
- Фильтр переводов строки до switch обязателен: коды 10 и 13 иначе доезжают до default пустышками после каждой команды.
- Две метки подряд — case 'o': case 'O': — ловят регистр одним телом; кириллическая тёзка кодом не совпадёт никогда, default с печатью кода это показывает.
- Команда с параметром читается в два приёма: буква — read, число — parseInt с пропуском нецифровых и таймаутом 1000 мс; результат зажимается constrain-ом.
- Гигиена пульта: подтверждение на каждую команду, статус по запросу — и лог остаётся читаемым даже при десятке команд.
Частые вопросы по теме урока
- Пользователь напечатал букву o. Что отдаст Serial.read()?
- Приём посимвольный: каждый символ — байт со своим кодом, 'o' — это 111. Поэтому switch по символу — тот же коммутатор, что и по числам. Один байт — код символа, у 'o' это 111
- Зачем перед switch стоит фильтр if (c != '\n' && c != '\r' && c != ' ')?
- Монитор порта приклеивает к посылке перевод строки, а в режиме Both NL & CR — ещё и возврат каретки: коды 10 и 13. Без фильтра они доедут до default. read() не умеет читать буквы
- Как одним телом ветви поймать и 'o', и 'O'?
- Пустое тело первой метки проваливается во вторую — это тот же fall-through, который без break считается граблёй, а тут работает на вас. Две метки подряд над одним телом: case 'o': case 'O':
- Отправлено b 500. Что вернёт Serial.parseInt() сразу после того, как read забрал букву b?
- Разбор пропускает всё до первой цифры и останавливается на первом нецифровом после числа. Поэтому b500 и b 500 равнозначны, а хвостовой перевод строки закрывает команду. 500: parseInt пропускает нецифровые символы до первого числа
- Что вернёт parseInt, если после 'b' число так и не пришло?
- Таймаут меняется Serial.setTimeout(). Ноль не безобиден: как параметр паузы он дёргает маяк на каждом обороте loop, поэтому результат сразу зажимают constrain-ом. 0 — после таймаута, по умолчанию 1000 мс