Диммер из урока про parseInt умел одно: число из монитора становится яркостью сразу и целиком. Отправишь 255 после нуля — и ночник над кроватью выстреливает в глаза вспышкой, как фотоаппарат. Я два вечера честно терпел, потом завёл цель — не «яркость», а «скорость к яркости»: плата не прыгает к заданному значению, а едет к нему шагами. Заодно у сервиса появился протокол вместо голого числа: команда «set 128» со словом, параметром и ответом — разница между игрушкой и прибором, которым можно пользоваться в темноте.
Протокол урока — три команды: set N задаёт цель от 0 до 255, stop гасит выход, status повторяет текущую задачу. Разбирается строка связкой readStringUntil(' ') — слово до пробела — и parseInt — число после; пробельный терминатор я уже разбирал в уроке про readStringUntil, там же механика секундного таймаута. Значение прогоняется через constrain(n, 0, 255) до analogWrite — и это не украшение, а страж, о котором ниже отдельно. Ответ платы — эхо вида duty=128 (50%): проценты считаются целочисленно, 128 × 100 / 255 = 50.
Железо: RGB-светодиод на пинах 9/10/11 — все три пина ШИМ, и регулятор сидит на красном канале, пине 9; зелёный и синий остаются свободными для расширения. Светодиод на D12 — лампочка «едем»: горит, пока выход движется к цели, гаснет на месте. Плавность тоже встроена в командный парсер не была — там команда исполнялась мгновенно; здесь исполнение растянуто во времени и потому живёт на millis().
Протокол: слово, пробел, число
Разбор строки «set 128» идёт в два движения. Первое: String cmd = Serial.readStringUntil(' ') — функция читает буфер до пробела и возвращает «set». Второе: int n = Serial.parseInt() — число после пробела, мониторовый перевод строки он не ест, хвост вычищает знакомый while-доещик. С командами stop и status есть нюанс: пробела в строке нет, readStringUntil дождётся секундного таймаута и отдаст всё, что накопила, вместе с невидимым \n. Сравнение «stop\n» == «stop» не сойдётся ни в одном байте, поэтому слово проходит через trim: cmd = cmd.trim(); — и хвосты исчезают для всех команд одинаково.
Почему слово плюс параметр, а не голое число, как в диммере: протокол с командами расширяется без ломки старого. Сегодня set, stop, status; завтра color 200 50 80 для RGB-каналов или speed 10 для темпа разгона — старые скрипты продолжают работать, потому что формат существующих команд не изменился. Голый цифровой протокол при первом же расширении превращается в загадку: что значит строка «200» — яркость, скорость или канал?
Эхо после каждой команды — не вежливость, а единственный способ увидеть, что плата поняла. Строка duty=128 (50%) печатается из двух кусков: число — цель, в скобках — проценты. Расчёт целочисленный: (n * 100) / 255. Промежуточное 128 × 100 = 12800 влезает в int (потолок 32767 — тема предыдущего урока), но уже 340 × 100 переполнило бы; constrain до этой арифметики гарантирует, что множитель не выйдет за 255, и int-произведение остаётся безопасным. Так две темы урока скреплены одной строкой кода.
| Команда | Ответ платы | Что произойдёт |
|---|---|---|
| set 128 | duty=128 (50%) | цель 255-шкалы, красный канал едет к 128 плавно |
| set 300 | duty=255 (100%) | constrain срезает до потолка ШИМ |
| set -20 | duty=0 (0%) | constrain прижимает к нулю, выход гаснет |
| stop | duty=0 (0%) | цель обнуляется, канал плавно гаснет |
| status | duty=128 (50%) | плата повторяет текущую цель без изменений |
const int PWM_PIN = 9; // красный канал RGBconst int LED = 12; // лампочка «едем»const int STEP_MS = 5; // мс на один шаг ШИМ int target = 0; // куда едемint cur = 0; // где сейчасunsigned long tLast = 0; void reportStatus() { Serial.print("duty="); Serial.print(target); Serial.print(" ("); Serial.print((target * 100) / 255); Serial.println("%)");} void setup() { pinMode(PWM_PIN, OUTPUT); pinMode(LED, OUTPUT); Serial.begin(9600); Serial.println("Komandy: set 0-255, stop, status");} void loop() { if (Serial.available() > 0) { String cmd = Serial.readStringUntil(' '); cmd = cmd.trim(); // снимаем хвостовой у однословных if (cmd == "set") { int n = Serial.parseInt(); while (Serial.available() > 0) { Serial.read(); } target = constrain(n, 0, 255); reportStatus(); } else if (cmd == "stop") { while (Serial.available() > 0) { Serial.read(); } target = 0; reportStatus(); } else if (cmd == "status") { while (Serial.available() > 0) { Serial.read(); } reportStatus(); } else { while (Serial.available() > 0) { Serial.read(); } Serial.print("Neponyal: "); Serial.println(cmd); } } if (cur != target) { // едем по одному шагу ШИМ if (millis() - tLast >= STEP_MS) { tLast = millis(); if (cur < target) { cur = cur + 1; } else { cur = cur - 1; } analogWrite(PWM_PIN, cur); digitalWrite(LED, HIGH); } } else { digitalWrite(LED, LOW); // на месте — лампочка гаснет }}constrain(n, 0, 255) — одна строка, которая стоит между монитором порта и пином. ШИМ-выход живёт в диапазоне 0-255, а из монитора приходит что угодно: 300, 9999, -20. Ядро Arduino такие значения молча приводит по своим правилам: всё, что больше 255, включит выход на полную, а отрицательное после приведения типов превратится в значение, которое вы не заказывали. constrain срезает всё лишнее в одном месте до вызова — и потолок, и дно, — а бонусом страхует целочисленный расчёт процентов: множитель не выйдет за 255, и произведение n × 100 не переполнит int.
У пары constrain есть дешёвая замена из двух if — я разбирал её в уроке про диммер. Здесь выбираю constrain по другой причине: защита живёт в одной строке рядом с analogWrite, и её невозможно забыть при добавлении новых команд. Это та же философия, что у эха: правило, сформулированное один раз в одном месте, надёжнее пяти разъяснений в комментариях.
Плавный ход: шаговик на millis
Механика хода — два состояния и таймер: target (цель) и cur (текущее значение), плюс отметка tLast. Каждый проход loop() скетч спрашивает: есть ли разница? Если есть и с прошлого шага прошло STEP_MS миллисекунд — cur сдвигается на единицу в сторону цели и analogWrite повторяет новое значение. Полный разгон 0-255 — это 255 шагов по 5 мс, 1,3 секунды; хотите медленнее — увеличьте константу, быстрее — уменьшите. LED на D12 горит ровно пока cur != target: гаснущая лампочка — честный признак «приехали».
Почему шаги, а не delay(5) в цикле разгона: delay заморозил бы разбор команд на все 1,3 секунды разгона, и «stop» во время движения просто не дошёл бы до платы. Millis-шаговик разбирает команды каждый проход — тормоз работает на середине разгона, и канал плавно разворачивается вниз. Это тот же принцип, что в уроке про millis вместо delay и в планировщике задач; здесь он собран в шести строках, на которых потом строятся и сервоприводы, и вентиляторы.
Выбор пина 9 — не случайность, а расписание таймеров: analogWrite на пинах 9 и 10 сидит на Timer1, а tone() занимает Timer2 — мелодия из урока про зуммер и ШИМ-регулятор уживаются на одной плате без конфликтов. А вот пины 3 и 11 кормятся от Timer2: если на них играет tone, ШИМ замолчит. Развязка таймеров разобрана в уроке про подключение зуммера — здесь она превращается из таблицы в рабочее правило раскладки пинов.
Про сам шаг стоит помнить две цифры. Частота ШИМ на пине 9 — около 490 Гц: глаз воспринимает такой мерцающий свет как ровный, и любые «плавности» между 0 и 255 — это лишь доля времени, которую пин проводит в единице. А базу механики — скважность, analogWrite и то, почему 128 из 255 означает половину периода, — я разбирал в уроке про ШИМ-яркость; здесь она используется как готовый фундамент, на который регулятор ставит только темп. Ниже — лаборатория ШИМ: подвигайте ползунок duty и посмотрите на осциллограмму — увидите глазами то самое заполнение, которым регулятор рисует яркость.
Скважность, частота и яркость светодиода — крутите ползунки и смотрите на осциллограмму.
Подгружаем интерактив…
Проверка в симуляторе
Отправьте set 200 и смотрите на красный канал: он едет к цели чуть больше секунды, лампочка D12 горит и гаснет на месте. Затем set 300 — эхо покажет duty=255 (100%): страж сработал, а вы увидели это в ответе, а не по странному поведению светодиода. Отправьте stop на середине разгона — канал развернётся вниз плавно, без рывка: команда дошла, потому что loop() не спал в delay.
Последняя проверка — на незнакомый ввод: строка hello вернёт «Neponyal: hello», и буфер останется чистым для следующей команды. Сравните ощущение с диммером-скачком из урока про parseInt: та же плата, те же 0-255, но сервис с протоколом, эхом и плавным ходом уже не стыдно вставить в ночник — а это и есть финальная цель трёх уроков этого кластера.
- Протокол «слово + параметр»: readStringUntil(' ') забирает слово, parseInt — число; для однословных команд readStringUntil отрабатывает таймаут 1000 мс
- cmd = cmd.trim() снимает невидимый перевод строки: сравнение «stop\n» со «stop» без trim не сходится ни в одном байте
- constrain(n, 0, 255) до analogWrite: диапазон ШИМ наведён в одном месте, мусорные 300 и -20 не доходят до пина
- Плавный ход: cur к target по 1 единице каждые 5 мс на millis — 0-255 за 1,3 с, команды разбираются и во время разгона
- Эхо duty=128 (50%) считает проценты целочисленно: n × 100 / 255; пин 9 — Timer1, tone — Timer2, конфликтов нет
- 1. Что вернёт readStringUntil(' ') для строки «stop\n», если пробела в ней нет?
- 2. Зачем constrain(n, 0, 255) стоит перед analogWrite(PWM_PIN, cur)?
- 3. Какое эхо напечатает плата после команды set 128?
- 4. Сколько займёт полный разгон 0-255 при шаге 1 единица каждые 5 мс?
- 5. Почему регулятор поставили на пин 9, а не на 11, если на плате ещё играет мелодия через tone()?
Частые вопросы по теме урока
- Что вернёт readStringUntil(' ') для строки «stop\n», если пробела в ней нет?
- Без терминатора readStringUntil копит всё до опустошения буфера, ждёт таймаут 1000 мс и возвращает накопленное вместе с невидимым \n. Сравнение с «stop» пройдёт только после trim. строку «stop\n»: терминатор не встретился, сработал таймаут 1000 мс
- Зачем constrain(n, 0, 255) стоит перед analogWrite(PWM_PIN, cur)?
- ШИМ-диапазон — 0-255. Большие значения ядро включит на полную, отрицательные после приведения типов дадут незаказанный duty. constrain режет всё лишнее одной строкой до вызова. чтобы мусорные 300 и -20 навели порядок до пина: ШИМ живёт в 0-255
- Какое эхо напечатает плата после команды set 128?
- Проценты считаются в целых: 128 × 100 = 12800, делённое на 255 даёт 50.19, дробная часть отброшена. Влезает в int благодаря constrain: множитель не больше 255. duty=128 (50%) — целочисленный расчёт 128 × 100 / 255
- Сколько займёт полный разгон 0-255 при шаге 1 единица каждые 5 мс?
- 255 шагов × 5 мс = 1275 мс. Темп меняется одной константой STEP_MS — скорость разгона не зашита в логику. около 1,3 секунды — 255 шагов по 5 мс
- Почему регулятор поставили на пин 9, а не на 11, если на плате ещё играет мелодия через tone()?
- ШИМ пинов 9 и 10 генерирует Timer1, звуковой таймер tone() — Timer2: разные железные блоки. А вот ШИМ пинов 3 и 11 при игре мелодии замолчит — Timer2 у них общий. ШИМ на 9 и 10 живёт на Timer1, tone — на Timer2: они не конфликтуют