Arduino: чтение ШИМ-сигнала с RC-приёмника
В большинстве радиоуправляемых моделей до сих пор широко используются приёмники с ШИМ (PWM) выходами. К такому выходу можно сразу подключить драйвер бесколлекторного двигателя (ESC), или сервомотор, или даже полётный контроллер дрона.
Конечно, в современных беспилотных системах в основном применяются другие протоколы: I.BUS, S.BUS и PPM, но простые и доступные радиоприёмники с несколькими ШИМ-каналами до сих пор широко распространены.
В этой инструкции мы будем подключать такой приёмник к Arduino-совместимой плате.
Список необходимых компонентов
Для выполнения всех экспериментов в данном уроке потребуются: отладочная плата UNO2 либо любая другая Arduino-совместимая плата, любая аппаратура радиоуправления с ШИМ-сигналом на приёмнике и немного проводов вилка-розетка.
Примечание
Uno 2 (Arduino-совместимая) с USB-кабелем, QIIC
Аппаратура управления RC-RX1 (6 каналов) с приёмником
Провода вилка-розетка, 40 шт., L=10 см
Что такое ШИМ-выход в RC-радиоприёмнике?
Сам по себе ШИМ-сигнал — это последовательность прямоугольных импульсов с одинаковым периодом, но с разной длиной самого импульса.
Изначально протокол управления на основе ШИМ был разработан специально для контроля положения сервомоторов. Как известно, аналоговые сервомоторы управляются ШИМ-сигналом с частотой 50 Гц и с изменяемой длиной импульса: импульс 1000 мкс — крайнее левое положение, 2000 мкс — крайнее правое, соответственно 1500 мкс — центральное.
Когда бесколлекторные двигатели стали набирать популярность, этот способ управления был приспособлен и для них. В такой схеме сигнал от радиоприёмника поступает на ESC — регулятор оборотов двигателя, где он декодируется и указывает регулятору с какой скоростью вращать мотор.
Более того, первые полётные контроллеры тоже научили считывать ШИМ-сигнал RC-приёмников.
И в случае ESC, и в случае автопилота, декодированием ШИМ занимается микроконтроллер. Собственно, в рамках этой инструкции мы повторим их работу в части считывания ШИМ-сигнала. В результате мы сначала получим конкретные числа, выражающие угол отклонения стиков аппаратуры радиоуправления, а затем используем их в своих корыстных целях.
Подключение
У типичного RC-радиоприёмника есть трёхконтактная гребёнка, в которой самый правый контакт — общий (земля), средний — питание, левый — сигнал. Как правило, питаются приёмники от 5 В. Например, схема контактов радиоприёмника FS-iA6 выглядит так:
Подключаем общий провод к GND на отладочной плате UNO. Питание к +5V, а сигнальные линии — к выбранным цифровым контактам.
Программа
Существуют разные способы декодирования ШИМ-сигнала на микроконтроллере:
можно считывать значения цифровых входов (
digitalRead) в суперцикле программы: ждём, пока на контакте появится высокий уровень, засекаем время, ждём, пока появится низкий;с помощью прерываний: то же самое, но вместо опроса контактов в суперцикле — используем прерывания;
с помощью встроенной в МК аппаратной функции чтения ШИМ: например, в STM32F103 — таймер в режиме PWM Input Mode.
Предупреждение
Первый способ самый неэффективный. Постоянный опрос digitalRead загружает микроконтроллер. Кроме того, появляется риск пропуска момента изменения сигнала. Его мы точно не будем использовать.
Третий способ — самый эффективный. Все вычисления полностью берёт на себя аппаратный таймер. Но не все микроконтроллеры поддерживают режим PWM Input Mode. Как раз ATmega328, стоящая в Arduino Uno, не умеет так делать. Более того, этот способ подразумевает работу с микроконтроллером на более низком уровне, что сильно выходит за рамки данной инструкции.
Таким образом, используем второй способ. Поможет нам в этом библиотека EnableInterrupt (ссылка в конце).
Напишем программу, которая будет считывать значение ШИМ сразу с четырёх каналов радиоприёмника, подключенных к контактам A0, A1, A2 и A3.
#include <EnableInterrupt.h>
#define RC_CHANNELS_Q 4
#define RC_CH1 0
#define RC_CH2 1
#define RC_CH3 3
#define RC_CH4 4
const byte rcPins[RC_CHANNELS_Q] = {A0, A1, A2, A3};
uint16_t rc_values[RC_CHANNELS_Q];
uint32_t rc_start[RC_CHANNELS_Q];
volatile uint16_t rc_shared[RC_CHANNELS_Q];
void readValues() {
noInterrupts();
memcpy(rc_values, (const void *)rc_shared, sizeof(rc_shared));
interrupts();
}
void calcInput(uint8_t channel, uint8_t input_pin) {
if (digitalRead(input_pin) == HIGH) {
rc_start[channel] = micros();
} else {
uint16_t rc_compare = (uint16_t)(micros() - rc_start[channel]);
rc_shared[channel] = rc_compare;
}
}
void calcCH1() { calcInput(RC_CH1, rcPins[RC_CH1]); }
void calcCH2() { calcInput(RC_CH2, rcPins[RC_CH2]); }
void calcCH3() { calcInput(RC_CH3, rcPins[RC_CH3]); }
void calcCH4() { calcInput(RC_CH4, rcPins[RC_CH4]); }
void setup() {
Serial.begin(115200);
pinMode( rcPins[RC_CH1], INPUT );
pinMode( rcPins[RC_CH2], INPUT );
pinMode( rcPins[RC_CH3], INPUT );
pinMode( rcPins[RC_CH4], INPUT );
enableInterrupt( rcPins[RC_CH1], calcCH1, CHANGE );
enableInterrupt( rcPins[RC_CH2], calcCH2, CHANGE );
enableInterrupt( rcPins[RC_CH3], calcCH3, CHANGE );
enableInterrupt( rcPins[RC_CH4], calcCH4, CHANGE );
}
void loop() {
readValues();
Serial.print("CH1:"); Serial.print( rc_values[RC_CH1] ); Serial.print("\t");
Serial.print("CH2:"); Serial.print( rc_values[RC_CH2] ); Serial.print("\t");
Serial.print("CH3:"); Serial.print( rc_values[RC_CH3] ); Serial.print("\t");
Serial.print("CH4:"); Serial.println( rc_values[RC_CH4] );
}
Загружаем программу на Uno и открываем монитор COM-порта. Если радиоприёмник корректно сопряжён с пультом, то при повороте стиков на подключенных каналах будут меняться значения.
Управление двухколёсным роботом
А теперь напишем более полезную программу, которая будет вращать двигателями двухколёсного робота в зависимости от положения стиков.
Логика работы будет такая:
Вычисляем значения длины импульса в ШИМ на двух каналах: первый — вертикальное положение джойстика, второй — горизонтальное.
если значение на вертикальном канале будет больше 1500 мкс — едем вперёд, если меньше — едем назад, иначе стоим на месте;
если значение на горизонтальном канале будет больше 1500 мкс — поворачиваем вправо, если меньше — влево, иначе стоим на месте.
Совет
В реальной обстановке центральное положение джойстика не даёт значение ровно 1500 — оно всегда будет немного отличаться. Поэтому введём минимальный порог срабатывания — RC_DEAD_GAP, равный 10 мкс. То есть, если, например, вертикальный джойстик даёт 1505, то мы игнорируем это изменение.
#include <EnableInterrupt.h>
#define RC_THRUST 0 // тяга
#define RC_STEERING 1 // руль
#define MOTOR_LEFT 0 // мотор 1
#define MOTOR_RIGHT 1 // мотор 2
#define MOVE_FWD 0
#define MOVE_BWD 1
#define MOVE_LEFT 2
#define MOVE_RIGHT 3
#define CTRL_TO 100 // период цикла управления
#define RC_PWM_MIN 1000
#define RC_PWM_MAX 2000
#define RC_DEAD_GAP 10
const byte rcPins[2] = {A0, A1};
const byte dirPins[2] = {3, 4};
const byte enPins[2] = {5, 6};
uint16_t rc_values[2];
uint32_t rc_start[2];
volatile uint16_t rc_shared[2];
uint32_t ctrl_next = 0;
void readValues() {
noInterrupts();
memcpy(rc_values, (const void *)rc_shared, sizeof(rc_shared));
interrupts();
}
void calcInput(uint8_t channel, uint8_t input_pin) {
if (digitalRead(input_pin) == HIGH) {
rc_start[channel] = micros();
} else {
uint16_t rc_compare = (uint16_t)(micros() - rc_start[channel]);
rc_shared[channel] = rc_compare;
}
}
void calcThrust() { calcInput(RC_THRUST, rcPins[RC_THRUST]); }
void calcSteering() { calcInput(RC_STEERING, rcPins[RC_STEERING]); }
void stop(){
analogWrite( enPins[MOTOR_LEFT], 0 );
analogWrite( enPins[MOTOR_RIGHT], 0 );
}
void move( uint16_t pwm, uint8_t dir ){
analogWrite( enPins[MOTOR_LEFT], pwm );
analogWrite( enPins[MOTOR_RIGHT], pwm );
switch( dir ){
case MOVE_FWD:
digitalWrite( dirPins[MOTOR_LEFT], HIGH );
digitalWrite( dirPins[MOTOR_RIGHT], HIGH );
break;
case MOVE_BWD:
digitalWrite( dirPins[MOTOR_LEFT], LOW );
digitalWrite( dirPins[MOTOR_RIGHT], LOW );
break;
case MOVE_LEFT:
digitalWrite( dirPins[MOTOR_LEFT], LOW );
digitalWrite( dirPins[MOTOR_RIGHT], HIGH );
break;
case MOVE_RIGHT:
digitalWrite( dirPins[MOTOR_LEFT], HIGH );
digitalWrite( dirPins[MOTOR_RIGHT], LOW );
break;
}
}
void setup() {
pinMode( enPins[MOTOR_LEFT], OUTPUT);
pinMode( enPins[MOTOR_RIGHT], OUTPUT);
pinMode( dirPins[MOTOR_LEFT], OUTPUT);
pinMode( dirPins[MOTOR_RIGHT], OUTPUT);
pinMode( rcPins[RC_THRUST], INPUT );
pinMode( rcPins[RC_STEERING], INPUT );
enableInterrupt( rcPins[RC_THRUST], calcThrust, CHANGE );
enableInterrupt( rcPins[RC_STEERING], calcSteering, CHANGE );
}
void loop() {
uint32_t t = millis();
if( t > ctrl_next ){
ctrl_next = t + CTRL_TO;
readValues();
uint32_t pwm;
// движение вперёд
if( rc_values[RC_THRUST] > 1500 + RC_DEAD_GAP ){
pwm = (rc_values[RC_THRUST] - 1500) * 255/(RC_PWM_MAX - RC_PWM_MIN);
move( pwm, MOVE_FWD );
} else
// движение назад
if( rc_values[RC_THRUST] < 1500 - RC_DEAD_GAP ){
pwm = (1500 - rc_values[RC_THRUST]) * 255/(RC_PWM_MAX - RC_PWM_MIN);
move( pwm, MOVE_BWD );
} else
// поворот налево
if( rc_values[RC_STEERING] < 1500 - RC_DEAD_GAP ){
pwm = (1500 - rc_values[RC_STEERING]) * 255/(RC_PWM_MAX - RC_PWM_MIN);
move( pwm, MOVE_LEFT );
} else
// поворот направо
if( rc_values[RC_STEERING] > 1500 + RC_DEAD_GAP ){
pwm = (rc_values[RC_STEERING] - 1500) * 255/(RC_PWM_MAX - RC_PWM_MIN);
move( pwm, MOVE_RIGHT );
} else {
stop();
}
}
}
Загружаем программу на UNO, подаём питание на робота и пробуем управлять.