Продвинутый курс Arduino · Секреты платы

Урок 2. Пульт из клавиатуры

Монитор порта превращается в пульт управления: с клавиатуры компьютера зажигаем свет любой яркости, крутим сервопривод и заказываем зуммеру мелодии. Заодно придумываем свой первый протокол.

Урок 2 из 8⏱ примерно 45 минутОбновлено: 14 августа 2026

Что понадобится

  • Контроллер Uno и кабель USB
  • Макетная плата
  • Светодиод и резистор 470 Ом
  • Зуммер
  • Сервопривод SG90
  • Провода

Все детали есть в обучающем наборе, по которому идёт курс.

В двенадцатом уроке базового курса монитор порта был окном, в которое плата пишет. Сегодня всё перевернётся: писать будете вы, а плата — слушаться. К концу урока у вас будет настоящий пульт управления: с клавиатуры компьютера вы будете зажигать свет любой яркости, поворачивать стрелку сервопривода на любой угол и заказывать зуммеру мелодии — как диджей.

А по дороге вы узнаете, как устроен протокол — язык, на котором договорились разговаривать два устройства. Именно так работает почти вся электроника вокруг: принтер и компьютер, телефон и наушники, банкомат и банк.

Как это устроено

Последовательный порт — мониторКоманды: L яркость, S угол, M мелодияУгол: 90S 90ОтправитьUSB-кабельL 0…255 — яркость светасветодиод на выводе 5 (ШИМ)S 0…180 — угол стрелкисервопривод на выводе 9+M 1…3 — мелодиязуммер на выводе 12Одна строка в поле ввода — одно действие платы. Договорённость о том, какие бываюткоманды и что они значат, называется протоколом.
Команда набирается в мониторе порта, уезжает по USB и превращается в действие. Каждой букве — свой исполнитель.

Сам канал связи вам уже знаком: это тот же последовательный порт из двенадцатого урока. Новость в том, что у него два направления. Команда Serial.read() умеет не только подглядывать за платой — она читает всё, что вы отправили ей из окна монитора.

Собираем схему

Три исполнителя — три знакомые детали: светодиод из пятого урока, сервопривод из двадцать второго и зуммер из девятнадцатого. Обратите внимание, куда встал светодиод: на вывод 5, а не на привычный десятый.

Почему именно вывод 5? В этой схеме встречаются два «пожирателя» таймеров: сервопривод забирает себе ШИМ на выводах 9 и 10 (помните врезку из урока про серво?), а зуммер, пока звучит, глушит ШИМ на выводах 3 и 11. Для плавной яркости остаются выводы 5 и 6 — их не трогает никто. Такие «коммунальные споры» деталей — обычное дело, и продумывать их заранее — часть работы инженера.

++abcdefghij1102036141719+123451Зуммер: «плюс» в столбик 3 → вывод 12; второй вывод — в шину «−».2Светодиод: вывод 5 → резистор 470 Ом → длинная ножка; короткая — в шину «−».3Перемычки справа: шины верхней и нижней половины сами по себе не соединены.4Сервопривод: оранжевый → вывод 9, красный → нижняя шина «+», коричневый → нижняя «−».5Питание шин: 5V платы → «+», GND платы → «−».Провод соединён только там, где на нём светлая шляпка; шины он пересекает между отверстиями.
Светодиод на выводе 5, зуммер на 12, сервопривод на 9. Шину «+» кормит 5V платы — это питание сервопривода.

Отключите 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… Кто соберёт из них число?

Вы отправили «S 90» — плата получила поток символов, по одному:S90Serial.read()буква командыSerial.parseInt()пробел пропустит, цифры соберёт в число 90«конец строки»Невидимые символы — пробел и «конец строки» — тоже приходят в плату. Пульт долженспокойно пропускать их мимо ушей, а не считать неизвестными командами.
read забирает букву команды, parseInt пропускает пробел и склеивает цифры в число.

Для этого у порта есть готовый помощник — 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 байт, поэтому диктовать плате целые поэмы не стоит.

← Все уроки курса