Продвинутый курс Arduino · Секреты платы
Урок 2. Пульт из клавиатуры
Монитор порта превращается в пульт управления: с клавиатуры компьютера зажигаем свет любой яркости, крутим сервопривод и заказываем зуммеру мелодии. Заодно придумываем свой первый протокол.
Что понадобится
- Контроллер Uno и кабель USB
- Макетная плата
- Светодиод и резистор 470 Ом
- Зуммер
- Сервопривод SG90
- Провода
Все детали есть в обучающем наборе, по которому идёт курс.
В двенадцатом уроке базового курса монитор порта был окном, в которое плата пишет. Сегодня всё перевернётся: писать будете вы, а плата — слушаться. К концу урока у вас будет настоящий пульт управления: с клавиатуры компьютера вы будете зажигать свет любой яркости, поворачивать стрелку сервопривода на любой угол и заказывать зуммеру мелодии — как диджей.
А по дороге вы узнаете, как устроен протокол — язык, на котором договорились разговаривать два устройства. Именно так работает почти вся электроника вокруг: принтер и компьютер, телефон и наушники, банкомат и банк.
Как это устроено
Сам канал связи вам уже знаком: это тот же последовательный порт из двенадцатого урока. Новость в том, что у него два направления. Команда Serial.read() умеет не только подглядывать за платой — она читает всё, что вы отправили ей из окна монитора.
Собираем схему
Три исполнителя — три знакомые детали: светодиод из пятого урока, сервопривод из двадцать второго и зуммер из девятнадцатого. Обратите внимание, куда встал светодиод: на вывод 5, а не на привычный десятый.
Почему именно вывод 5? В этой схеме встречаются два «пожирателя» таймеров: сервопривод забирает себе ШИМ на выводах 9 и 10 (помните врезку из урока про серво?), а зуммер, пока звучит, глушит ШИМ на выводах 3 и 11. Для плавной яркости остаются выводы 5 и 6 — их не трогает никто. Такие «коммунальные споры» деталей — обычное дело, и продумывать их заранее — часть работы инженера.
Отключите USB перед сборкой и проверьте полярность: у зуммера «плюс», у светодиода — длинная ножка, а у сервопривода красный провод обязан прийти на «+» шины. Перепутанные провода серво — самая частая причина «стрелка дёргается и не слушается».
Шаг 1. Учимся слышать: эхо
Сначала — никаких исполнителей. Просто убедимся, что плата слышит каждый символ.
// Продвинутый курс, урок 2. Шаг 1: плата отвечает на каждый символ.
void setup() {
Serial.begin(9600);
Serial.println("Напишите что-нибудь — я повторю!");
}
void loop() {
if (Serial.available() > 0) { // в почтовом ящике что-то лежит?
char c = Serial.read(); // забираем ОДИН символ
int code = c; // у каждого символа есть числовой код
Serial.print("Получил символ: ");
Serial.print(c);
Serial.print(" (код ");
Serial.print(code);
Serial.println(")");
}
}
Загрузите, откройте монитор порта и отправьте что-нибудь: букву, цифру, целое слово. Плата вернёт каждый символ по отдельности — и его числовой код. Для платы буква A — это число 65, пробел — число 32. Строка int code = c; просто перекладывает символ в числовую переменную — и секрет раскрыт: char всегда был числом, мы лишь смотрели на него как на букву.
Отправьте слово из трёх букв — и обратите внимание: в конце списка почти наверняка мелькнёт символ с кодом 10, которого вы не набирали. Это «конец строки» — невидимый знак «я закончил предложение». Запомните его: чуть позже он попробует притвориться командой.
Шаг 2. Первая команда
Теперь подключим свет. Договоримся по-простому, как в брошюре набора: символ 1 — включить, 0 — выключить.
// Продвинутый курс, урок 2. Шаг 2: свет слушается клавиш 1 и 0.
const int LED = 5;
void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
Serial.println("Команды: 1 — включить свет, 0 — выключить.");
}
void loop() {
if (Serial.available() > 0) {
char c = Serial.read();
if (c == '1') {
digitalWrite(LED, HIGH);
Serial.println("Свет включён.");
}
if (c == '0') {
digitalWrite(LED, LOW);
Serial.println("Свет выключен.");
}
// Все остальные символы — включая невидимый «конец строки» —
// просто пропускаем: это не команды.
}
}
Здесь всё знакомо: if сравнивает присланный символ с образцом. Одинарные кавычки — примета символа: '1' — это символ единицы (код 49), а вовсе не число 1. А невидимый «конец строки» спокойно проходит мимо обеих проверок — на него плата просто не реагирует.
Шаг 3. Команда с числом
«Вкл» и «выкл» — слишком грубо для нашего светодиода: он ведь умеет 256 оттенков яркости. Хочется командовать так: L 200 — и яркость становится 200. Но из порта символы выходят по одному: L, пробел, 2, 0, 0… Кто соберёт из них число?
Для этого у порта есть готовый помощник — Serial.parseInt(): «подожди цифр и собери из них целое число». Пробелы перед числом он пропускает сам.
// Продвинутый курс, урок 2. Шаг 3: команда из буквы и числа.
const int LED = 5;
void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
Serial.println("Команда: L яркость. Например: L 200");
}
void loop() {
if (Serial.available() > 0) {
char c = Serial.read();
if (c == 'L' || c == 'l') { // команду принимаем в любом регистре
int value = Serial.parseInt(); // выуживаем из потока число
value = constrain(value, 0, 255); // страхуемся от чисел не по росту
analogWrite(LED, value);
Serial.print("Яркость: ");
Serial.println(value);
}
}
}
Попробуйте: L 200, L 30, L 0. Знакомая по пятнадцатому уроку constrain страхует от слишком больших чисел: команда L 999 честно превратится в 255, а не сломает яркость.
Маленькая странность parseInt: если отправить букву L без числа, плата на секунду задумается и объявит яркость 0. Так устроен таймаут: parseInt ждёт цифр одну секунду, не дожидается — и возвращает ноль. Для нашего пульта это терпимо, но помнить об этом стоит.
Шаг 4. Второй исполнитель и switch
Добавляем сервопривод: команда S 90 — стрелка на 90 градусов. Два if ещё терпимо, но команд будет больше — самое время вспомнить switch из урока про датчик DHT11: он создан ровно для таких «развилок по значению».
// Продвинутый курс, урок 2. Шаг 4: свет и стрелка в одном пульте.
#include <Servo.h>
const int LED = 5;
const int SERVO_PIN = 9;
Servo strelka;
void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
strelka.attach(SERVO_PIN);
strelka.write(90);
Serial.println("Команды: L яркость (0-255), S угол (0-180).");
}
void loop() {
if (Serial.available() > 0) {
char c = Serial.read();
switch (c) {
case 'L':
case 'l': {
int value = constrain(Serial.parseInt(), 0, 255);
analogWrite(LED, value);
Serial.print("Яркость: ");
Serial.println(value);
break;
}
case 'S':
case 's': {
int angle = constrain(Serial.parseInt(), 0, 180);
strelka.write(angle);
Serial.print("Угол: ");
Serial.println(angle);
break;
}
}
}
}
Две новинки. Во-первых, пары case 'L': case 'l': — два ярлыка подряд ведут к одному и тому же коду, поэтому пульт понимает и большие, и маленькие буквы. Во-вторых, фигурные скобки внутри case: они нужны, потому что мы заводим там переменную. Правило простое: объявляете переменную внутри case — оберните его тело в скобки.
Шаг 5. Музыкальный автомат
Третий исполнитель — зуммер. Команда M с номером заказывает мелодию: гамма, сирена или сигнал победы из игры «Повтори за мной».
// Продвинутый курс, урок 2. Шаг 5: зуммер играет мелодию по номеру.
#include <Servo.h>
const int LED = 5;
const int SERVO_PIN = 9;
const int BUZZER = 12;
Servo strelka;
void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
strelka.attach(SERVO_PIN);
strelka.write(90);
Serial.println("Команды: L яркость, S угол, M мелодия (1-3).");
}
void loop() {
if (Serial.available() > 0) {
char c = Serial.read();
switch (c) {
case 'L':
case 'l': {
int value = constrain(Serial.parseInt(), 0, 255);
analogWrite(LED, value);
Serial.print("Яркость: ");
Serial.println(value);
break;
}
case 'S':
case 's': {
int angle = constrain(Serial.parseInt(), 0, 180);
strelka.write(angle);
Serial.print("Угол: ");
Serial.println(angle);
break;
}
case 'M':
case 'm': {
int number = Serial.parseInt();
playMelody(number);
break;
}
}
}
}
void playMelody(int number) {
Serial.print("Мелодия ");
Serial.println(number);
if (number == 1) { // гамма из урока 19
int notes[8] = {262, 294, 330, 349, 392, 440, 494, 523};
for (int i = 0; i < 8; i++) {
tone(BUZZER, notes[i], 180);
delay(220);
}
} else if (number == 2) { // сирена
for (int k = 0; k < 3; k++) {
for (int f = 400; f <= 900; f = f + 10) {
tone(BUZZER, f);
delay(4);
}
for (int f = 900; f >= 400; f = f - 10) {
tone(BUZZER, f);
delay(4);
}
}
noTone(BUZZER);
} else if (number == 3) { // сигнал победы из урока 30
tone(BUZZER, 523, 120);
delay(150);
tone(BUZZER, 659, 120);
delay(150);
tone(BUZZER, 784, 250);
delay(280);
noTone(BUZZER);
} else {
Serial.println("Такой мелодии нет, есть 1, 2 и 3.");
}
}
Каждая мелодия — это код из прошлых уроков, сложенный в функцию playMelody. Пока она играет, пульт глохнет — мелодия построена на delay. Для музыкальной шкатулки это нормально; как сделать иначе, вы уже знаете по уроку о шлагбауме, а потренируетесь — в задании.
Шаг 6. Пульт, которым приятно пользоваться
Настоящие устройства отличаются от поделок вежливостью: они рассказывают, что умеют, и не молчат в ответ на опечатку. Добавим команду-справку ? и ответ на незнакомые символы.
// Продвинутый курс, урок 2. Шаг 6: пульт со справкой и защитой от опечаток.
#include <Servo.h>
const int LED = 5;
const int SERVO_PIN = 9;
const int BUZZER = 12;
Servo strelka;
void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
strelka.attach(SERVO_PIN);
strelka.write(90);
printHelp();
}
void loop() {
if (Serial.available() > 0) {
char c = Serial.read();
// Служебные символы приходят вместе с командой — молча пропускаем.
if (c == ' ' || c == '\n' || c == '\r') {
return;
}
runCommand(c);
}
}
void runCommand(char c) {
switch (c) {
case 'L':
case 'l': {
int value = constrain(Serial.parseInt(), 0, 255);
analogWrite(LED, value);
Serial.print("OK. Яркость: ");
Serial.println(value);
break;
}
case 'S':
case 's': {
int angle = constrain(Serial.parseInt(), 0, 180);
strelka.write(angle);
Serial.print("OK. Угол: ");
Serial.println(angle);
break;
}
case 'M':
case 'm':
playMelody(Serial.parseInt());
break;
case '?':
printHelp();
break;
default:
Serial.print("Не знаю команду \"");
Serial.print(c);
Serial.println("\". Наберите ? — покажу справку.");
}
}
void printHelp() {
Serial.println("---- ПУЛЬТ ----");
Serial.println("L 0..255 — яркость света");
Serial.println("S 0..180 — угол стрелки");
Serial.println("M 1..3 — мелодия");
Serial.println("? — эта справка");
}
void playMelody(int number) {
if (number == 1) {
int notes[8] = {262, 294, 330, 349, 392, 440, 494, 523};
for (int i = 0; i < 8; i++) {
tone(BUZZER, notes[i], 180);
delay(220);
}
} else if (number == 2) {
for (int f = 400; f <= 900; f = f + 10) {
tone(BUZZER, f);
delay(4);
}
noTone(BUZZER);
} else if (number == 3) {
tone(BUZZER, 523, 120);
delay(150);
tone(BUZZER, 659, 120);
delay(150);
tone(BUZZER, 784, 250);
delay(280);
noTone(BUZZER);
} else {
Serial.println("Такой мелодии нет, есть 1, 2 и 3.");
}
}
Обратите внимание на первые строки loop: пробелы и «концы строк» мы теперь отбрасываем до разбора команды — иначе каждый «конец строки» получал бы в ответ обидное «не знаю команду». А ветка default в switch — это «все остальные случаи»: именно она превращает опечатку в подсказку, а не в тишину.
Проверьте пульт целиком: ? печатает справку; L 128 зажигает свет вполсилы; S 0 и S 180 укладывают стрелку в крайние положения; M 2 воет сиреной; X получает вежливый отказ со ссылкой на справку.
Частые проблемы
- Плата «не слышит» команды. Проверьте скорость: в мониторе должно стоять 9600, как в
Serial.begin. И убедитесь, что скетч загружен именно тот. - На каждую команду прилетает «не знаю команду». Скорее всего, вы удалили строки, пропускающие пробел и «конец строки», — вернитесь к началу шестого шага.
- Яркость всегда 0. Между буквой и числом должна быть либо ничья —
L200, либо пробел —L 200. А вотL, 200с запятой parseInt поймёт неправильно: запятая остановит разбор. - Стрелка дрожит или не двигается. Красный провод серво — на «+», коричневый — на «−», и шина «+» обязана быть соединена с 5V платы. Без питания серво команды бесполезны.
Задания для тренировки
- РазминкаДобавьте команду
B— короткий одиночный «бип» зуммером, без номера и без мелодии. Не забудьте вписать её в справку. - ПосложнееНаучите пульт команде
A— «автоматический режим»: стрелка раз в две секунды прыгает в случайный угол, а любая другая команда режим выключает. Пригодитсяrandomиз урока про игру на реакцию. - Со звёздочкой ⭐Сделайте команду
Lплавной: свет не прыгает к новой яркости, а доезжает до неё за полсекунды — и всё это время пульт продолжает слушать порт. Разбор ниже.
Показать разбор «плавная яркость»
Секрет — в паре переменных current и target из урока про умный ночник. Команда меняет только цель, а к цели свет ползёт маленькими шажками по расписанию millis() — ровно так же, как шлагбаум опускал стрелу.
// Продвинутый курс, урок 2. Разбор задания: яркость меняется плавно.
// Пульт продолжает слушать порт, пока свет доезжает до цели.
const int LED = 5;
int current = 0; // яркость сейчас
int target = 0; // яркость, которую попросили
unsigned long lastStep = 0; // когда делали последний шажок
void setup() {
Serial.begin(9600);
pinMode(LED, OUTPUT);
Serial.println("Команда: L яркость. Свет доедет до неё плавно.");
}
void loop() {
// 1. Слушаем порт — как обычно, без задержек.
if (Serial.available() > 0) {
char c = Serial.read();
if (c == 'L' || c == 'l') {
target = constrain(Serial.parseInt(), 0, 255);
Serial.print("Еду к яркости ");
Serial.println(target);
}
}
// 2. Раз в 4 мс делаем один шажок к цели — это и есть плавность.
if (millis() - lastStep >= 4) {
lastStep = millis();
if (current < target) {
current = current + 1;
}
if (current > target) {
current = current - 1;
}
analogWrite(LED, current);
}
}
Заметьте: в этом скетче нет ни одного delay. Поэтому пульт слышит новую команду даже посреди перехода — отправьте L 255 и сразу L 0, и свет развернётся на полпути.
Что вы теперь умеете
- принимать данные с компьютера:
Serial.readиSerial.parseInt; - видеть в символе число и не бояться невидимых символов;
- придумывать протокол «буква + число» и раздавать команды через
switch; - писать вежливые устройства: справка по
?, ответ на опечатку; - разводить детали по выводам так, чтобы они не спорили за таймеры.
Пульт слушается, пока плата свободна. А что, если событие случится в тот момент, когда она занята? Об этом — следующий урок: научим события врываться в программу, не спрашивая разрешения.
Частые вопросы
Почему команды набираются английскими буквами?
Команда — это один символ, который плата сравнивает с образцом. Латинские буквы приходят одним байтом, поэтому сравнение простое: c == 'L'. Русские буквы занимают два байта и в такую проверку не помещаются. Для протоколов это обычное дело: команды — латиницей, а вот ответы плата спокойно печатает по-русски.
Можно ли управлять платой не из монитора порта, а из своей программы?
Да, в этом и сила: монитор порта — лишь одно из окон в тот же канал. Любая программа на компьютере, умеющая открывать последовательный порт, сможет слать те же команды. Протокол вы уже придумали — осталось написать для него любого собеседника.
Что будет, если отправить сразу несколько команд одной строкой?
Плата разберёт их по очереди: символы лежат в приёмном буфере и ждут, пока loop() их прочитает. Строка «L 255 S 90» выполнится как две команды подряд. Буфер, правда, не бездонный — около 64 байт, поэтому диктовать плате целые поэмы не стоит.
