Гирлянда на шкафу давно была готова к празднику, но жила по страшному распорядку: чтобы сменить эффект, нужно снять ленту, дотащить её до стола, шить десять секунд и вешать обратно. Пять минут телодвижений ради двадцати секунд результата — и я делал так до тех пор, пока не поймал себя на том, что не переключаю эффекты вовсе: лента годами крутит один и тот же дождь. Лечится USB-кабелем и протоколом из четырёх слов: печатаете «kometa» в монитор порта — лента переезжает на другой эффект. Всё для этого уже есть: строки из порта мы научились ловить в уроке про readStringUntil, а кадры эффектов собираем каркасом из урока про эффекты.
За три сезона я перепробовал для той самой гирлянды три интерфейса: ИК-пульт (терялся), энкодер (отвалился вместе с кронштейном) и Serial — он жив до сих пор, потому что ноутбук и кабель есть всегда. Для стола и отладки альтернативы Serial я не вижу: ноль новой электроники, протокол в три строки и каждое слово команды видно в журнале. А протокол прирос сам собой: сначала был только выбор эффекта, потом понадобились «погаси», «а какой щас включён?», «сделай побыстрее». Хороший Serial-протокол растёт как дом — комнату за комнатой, без перестройки фундамента, поэтому с первого дня в нём живёт ветка «не понял»: будущие расширения лягут в неё строкой кода, а не переписыванием.
Протокол: четыре слова и знак вопроса
Плейлист живёт в одной переменной mode: 0 — радуга, 1 — комета, 2 — искры, 3 — погасить. Каждый кадр loop() рисует ровно один режим в функции drawFrame() — та же дисциплина «одно место на одно действие», из-за которой кнопочный пульт в следующем уроке соберётся за десять минут: там поменяется только способ записи в mode, а рисование останется как есть. Команды — слова, потому что их набирает человек: raduga, kometa, iskry, stop. На любое незнакомое слово скетч не молчит, а отвечает подсказкой со списком — молчание в протоколе превращает опечатку в полчаса чтения кода.
| Команда | Режим | Ответ в Serial |
|---|---|---|
| raduga | 0 — бегущая радуга | mode=raduga |
| kometa | 1 — огонёк с хвостом | mode=kometa |
| iskry | 2 — искры на затухании | mode=iskry |
| stop | 3 — лента погашена | mode=stop |
| ? | режим не меняет | список команд одной строкой |
| status | режим не меняет | mode=... и параметры одной строкой |
Слово «режим» здесь не украшение. Худший вариант, который я видел (и сам писал): четыре эффекта прямо в loop() с собственными таймерами и флагами. Переключение ломает кадр посередине: старый эффект не «отпущен», новый не «взят» с чистого листа, и лента на полсекунды показывает кашу из двух. В переменной mode вся перезагрузка — одна строчка: следующий кадр начнётся с чистого буфера, потому что каждая ветка drawFrame() сама приводит ленту к своему виду — fill_solid для стопа, fadeToBlackBy для искр.
Отсюда мой тест на качество плейлиста: добавьте пятый эффект одной веткой в drawFrame() и одной строкой в if-цепочку. Если пришлось лезть в таймеры — каркас протёк; у нас всё дописывается в два места, и оба на виду.
Чтение устроено по трём правилам, каждое из которых — шрам. Первое: читать только когда Serial.available() > 0 — в уроке про available и read это байт-копилка, а здесь страховка от таймаута, о нём ниже. Второе: readStringUntil вызывается ровно один раз на команду и складывается в переменную — второй вызов в том же проходе увидит пустой буфер. Третье: сразу cmd.trim() — монитор порта подкидывает невидимые пробелы и возврат каретки, из-за которых «kometa» с хвостиком не равна «kometa», и команда тихо не срабатывает.
Радуга и искры здесь собраны из того же набора, что в уроке про fill_rainbow и искры на затухании — на живой плате в искры добавится random8() вместо random(), а в симуляторе и без него видно всё, что важно: смену режимов и честные ответы в Serial. Обратите внимание на дисциплину веток: каждая из них начинает с затирания или затухания, поэтому после «stop» радуга не вспыхнет поверх старого кадра, а выстроится с чистого буфера. Если добавить режим, который рисует поверх прежнего — например, вторую комету поверх радуги, — тогда затухание придётся делать условным, и это будет осознанное решение, а не случайный каше-кадр.
#include <FastLED.h> #define LED_PIN 6#define NUM_LEDS 60 CRGB leds[NUM_LEDS];String cmd = "";int mode = 0;int hue = 0;int head = 0;int dir = 1;unsigned long lastFrame = 0; void drawFrame() { if (mode == 0) { fill_rainbow(leds, NUM_LEDS, hue, 7); hue += 1; } if (mode == 1) { fadeToBlackBy(leds, NUM_LEDS, 60); leds[head] = CHSV(30, 220, 255); head += dir; if (head >= NUM_LEDS - 1) { dir = -1; } if (head <= 0) { dir = 1; } } if (mode == 2) { fadeToBlackBy(leds, NUM_LEDS, 45); if (random8() < 50) { leds[random8(NUM_LEDS)] = CHSV(40, 200, 255); } } if (mode == 3) { FastLED.clear(); }} void setup() { FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.setBrightness(180); Serial.begin(115200); Serial.println("Komandy: raduga / kometa / iskry / stop / ?");} void loop() { if (Serial.available() > 0) { cmd = Serial.readStringUntil('\n'); cmd.trim(); if (cmd == "raduga") { mode = 0; Serial.println("mode=raduga"); } else if (cmd == "kometa") { mode = 1; Serial.println("mode=kometa"); } else if (cmd == "iskry") { mode = 2; Serial.println("mode=iskry"); } else if (cmd == "stop") { mode = 3; Serial.println("mode=stop"); } else if (cmd == "?") { Serial.println("raduga kometa iskry stop"); } else if (cmd.length() > 0) { Serial.println("Ne ponyal. ? - spisok"); } } if (millis() - lastFrame >= 30) { lastFrame = millis(); drawFrame(); FastLED.show(); }}Теперь тот же протокол в симуляторе — и здесь он работает интерактивно: печатайте команду в строку монитора порта и жмите Enter, лента отвечает сразу. Попробуйте три сценария подряд: сменить эффект на ходу, послать незнакомое слово, спросить «?» — и заметьте, что анимация не замирает ни на мгновение: чтение порта сидит в том же неблокирующем loop() и не трогает таймер кадров. Заодно поглядите на ответы: у корректного обмена на каждую строку от вас есть строка от платы, и пустота в журнале — тоже диагноз.
Таймаут, эхо и невидимые символы
Таймаутная ловушка стоит отдельным абзацем. На живой плате readStringUntil ждёт терминатор до 1000 мс по умолчанию: читать без проверки available() — значит платить секундой за каждый проход loop(), а монитор в режиме «Без знака окончания» не присылает терминатор вовсе, и каждая команда срабатывает с задержкой в секунду. Лечение бесплатное: available() перед чтением и режим «Новая строка» в мониторе. Таймаут настраивается и напрямую: Serial.setTimeout(50) режет цену промаха с тысячи миллисекунд до пятидесяти — я ставлю его в setup() во всех скетчах с readStringUntil и за полгода ни разу не пожалел. Если эффекты идут по тридцать кадров в секунду, секунда зависания выглядит как полный обморок анимации — и ищут его обычно в ленте, а не в порту. В симуляторе ниже поведение то же самое: движок честно выдерживает секунду ожидания, когда терминатора нет, так что привычку available()-перед-чтением можно наработать прямо здесь, на живом скетче.
Дальше протокол можно ужать или расширить. Числовые команды из урока про parseInt удобны, когда режимов много: «11» — «12» — «13» без всяких словарей. Для шести-семи эффектов мне хватает слов: они читаются в журнале сами, без легенды. А если каждый байт на счету — берите односимвольные команды «r», «k», «i», «s» и switch по cmd.charAt(0): одна команда на 9600 бод стоит миллисекунду против шести за «kometa», и при кадре в 30 мс это уже заметно. На 115200 бод разница исчезает, поэтому мой выбор по умолчанию — слова. И знак вопроса в таблице не случайно: справка — тоже команда, и её удобно проверять первой после каждой прошивки — она сразу показывает, что живой именно новый скетч.
Когда протокол переживёт первую неделю, придёт запрос «а сделай ярче». Удобная конвенция — команда-глагол и число-параметр в одной строке: «speed 3», «brt 120». Читается в два шага: до первого пробела — команда, остаток через parseInt — число, и вот погода скорости и яркости управляется тем же портом. Второй гость любого приличного протокола — команда status: она печатает mode= и параметры, и вечный вопрос «а какой эффект щас включён» снимается без вспомогательных светодиодов.
И про эхо, оно половина протокола. Первые дни моя гирлянда принимала команды молча: лента меняла эффект, а журнал пуст, и я не мог отличить «команда дошла» от «команда потерялась по дороге». С тех пор плата отвечает на каждое слово — mode=... при смене и список на незнакомое — и Serial превращается в протокол испытаний, по которому через месяц ещё можно понять, что происходило этой ночью.
- 1. Что случится на живой плате, если вызывать readStringUntil() каждый проход loop() без проверки available()?
- 2. Монитор порта стоит в режиме «Без знака окончания». Что почувствует анимация?
- 3. Зачем после чтения вызывать cmd.trim()?
- 4. Плейлист из шести эффектов, командами пользуется человек через USB. Какой парсинг дешевле для UART?
- 5. Пришло слово «krasnyi», которого нет в протоколе. Что ответит приличный парсер?
- available() перед чтением — иначе на живой плате каждый проход loop() стоит секунды таймаута readStringUntil.
- Один вызов readStringUntil на команду, сразу trim(), ответ печатается всегда: mode=... или подсказка.
- Незнакомая команда получает список вариантов, а не молчание — опечатка не должна стоить полчаса отладки.
- Каркас плейлиста: переменная mode + одна drawFrame() на кадр — новый пульт управления меняет только запись в mode.
- Слова понятны человеку, символы дешевле для UART: при кадре 30 мс и 9600 бод каждая буква команды стоит миллисекунду.
Частые вопросы по теме урока
- Что случится на живой плате, если вызывать readStringUntil() каждый проход loop() без проверки available()?
- readStringUntil ждёт терминатор до 1000 мс по умолчанию. Без available() этот таймаут платится за каждый проход loop — анимация обморочно замирает, Serial.setTimeout(50) смягчает цену промаха. Каждый проход будет стоить до секунды — таймаут по умолчанию 1000 мс
- Монитор порта стоит в режиме «Без знака окончания». Что почувствует анимация?
- Без \n readStringUntil дожидается полного таймаута в 1000 мс и только потом отдаёт строку. Режим «Новая строка» в мониторе лечит это бесплатно. Каждая команда сработает с задержкой около секунды — терминатора нет
- Зачем после чтения вызывать cmd.trim()?
- Мониторы подкидывают пробелы и возврат каретки: «kometa\r» не равна «kometa», и команда тихо не срабатывает. trim() срезает хвосты. Убрать невидимые пробелы и \r, из-за которых сравнение не проходит
- Плейлист из шести эффектов, командами пользуется человек через USB. Какой парсинг дешевле для UART?
- Односимвольная команда — 1 байт и около 1 мс на 9600 бод, слово из шести букв — 6 мс. Для протокола человек-машина слова нагляднее, но символы дешевле. Один символ на команду: 1 байт вместо 7–10, и влезает в switch
- Пришло слово «krasnyi», которого нет в протоколе. Что ответит приличный парсер?
- Ветка «не понял» обязана отвечать подсказкой: молчание превращает опечатку в полчаса чтения кода. У нас это println со списком команд — и он же пригодится новым командам протокола. Подсказку со списком команд