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 выглядит так:

Схема контактов RC-приёмника 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, подаём питание на робота и пробуем управлять.

Полезные ссылки