Беспроводный руль для компьютерных гонок и леталок

scientificman

✩✩✩✩✩✩✩
28 Мар 2021
3
4
Приветствую всех самодельщиков!
Позвольте представить вам для обсуждения/повторения проект беспроводного, мобильного руля-штурвала. Название проекта - LeanDrive. Контроллер не привязан к столу, удерживается руками в воздухе. Для управления используются наклоны/повороты руля, две аналоговых педали (газ-тормоз или что хотите) и 6 кнопок. Руль определяется системой как геймпад и работает с подавляющим большинством игр. Если игра хочет проприетарный контроллер (типа XBox-овского), то можно использовать программы "переходники" типа x360ce.

Руль собран на отладочной плате ESP32 WROOM. Наклон контролируется гироскопом-акселерометром MPU-6050/ Корпус напечатан на 3D-принтере. Печатные платы делали сами на небольшом фрезере с ЧПУ (CNC1610). Это уже вторая версия. Первый прототип был более простой и проводный на Atmega 32U4, как и руль автора сайта.

Презентационное видео, в котором описываются особенности устройства, проводятся тесты и кратко описываются технические детали. В комментариях к видео есть ссылки на все модели, детали, схемы, код. Ниже я их продублирую.

3D модели размещены на сайте 3dtoday.
LeanDrive - беспроводный руль для компьютерных гонок

Электронная часть. К сожалению, платы проектировались отдельно на SprintLayout (EasyEDA освоил чуть позже). Они приаттачены к проекту в соответствующем формате. Могу выложить гербер или картинки для ЛУТ. Там же есть и код.
Проект на EasyEDA

Код написан на ArduinoIDE с установленным драйвером для ESP32.
Проект для Arduino IDE:
/*
 * LeanDrive, Bluetooth руль для компьютерных игр
 * https://www.youtube.com/watch?v=BMJRcHLejD8&t
 * TERRABYTE channel
 * 
 * v. 1.1 от 30.03.2021 
 *     Добавлено: 
 *     - автоматическая калибровка педалей.
 *     Исправлено: 
 *     - Некорректное отображение (озвучивание) уровней заряда аккумулятора.
 * 
 * v. 1.0 от 22.03.2021 
 *     Базовая версия
 */
 
#include <BleGamepad.h>
#include <WiFi.h>
#include "Wire.h"

// Назначение пинов контроллера 
#define ACCELERATOR_PIN 27 // Педаль газа
#define BREAK_PIN 14 // Педаль тормоза
#define BTN_LEFT_CASE_PIN 33 // Пин левой кнопки на корпусе (нумерация на плате)
#define BTN_RIGHT_CASE_PIN 32 // Пин правой кнопки на корпусе
#define BTN_LEFT_TOP_HANDLE_PIN 4 // Пин левой верхней кнопки на руле
#define BTN_LEFT_BOTTOM_HANDLE_PIN 5 // Пин левой нижней кнопки на руле
#define BTN_RIGHT_TOP_HANDLE_PIN 13 // Пин правой верхней кнопки на руле
#define BTN_RIGHT_BOTTOM_HANDLE_PIN 12 // Пин правой нижней кнопки на руле
#define ACCUM_PIN 15 // Уровень заряда аккумулятора
#define SOUND_PIN 26 // Пин динамика
#define MPU_ADDR 0x68 // Адрес гироскопа

#define SOUND_FREQ 2000 // Частота звука динамика по умолчанию
#define SOUND_DUTY 128 // Скважность импульсов тона 0...255 (128 - 50%)
#define SOUND_RESOLUTION 8 // Разрешение по скважности в битах
#define SOUND_CHANNEL 0 // Канал таймера ШИМ
#define SOUND_INDICATOR_PERIOD 60000 // Период опроса датчика заряда аккумулятора
#define SOUND_PULSE_TIME 200 // Длительность звукового импульса
#define SOUND_PULCES 5 // Количество импульсов в звуке "аккумулятор менее 20 процентов"

#define ACCUM_LOW_ADC 1770 // Нижний уровень заряда аккумулятора в уровне АЦП. Зависит от делителя напряжения.
#define ACCUM_HI_ADC 2370 // Верхний уровень заряда аккумулятора в уровне АЦП

#define TIME_ROUND 20 // период опроса кнопок руля (мс)

#define MODE_SWITCH_TIME 3000 // Время зажатия кнопок для переключения режима (секунд);

enum drive_mode_t {CAR_MODE, FLY_MODE}; // Режим работы руля: автомобиль или самолет (влияет на интерпретацию значения педалей - как разные оси или как одна ось)

uint32_t last_time = 0; // Время последнего опроса
uint32_t last_accum_time = 0; // Время последнего опроса аккумулятора
uint32_t pulse_time = 0; // Время импульса звука или паузы
uint32_t mode_time = 0; // Время зажатия клавиш переключения режима руля

// Ускорения, угловые скорости, температура
int16_t ax, ay, az, gx, gy, gz, temperature;

int32_t accelerator_value = 0; // Значение с АЦП педали газа
int32_t break_value = 0; // Значение с АЦП педали тормоза
int32_t accelerator_low_level = 1250; // Нижний порог педали ускорения
int32_t break_low_level = 1450; // нижний порог педали тормоза
//int16_t accelerator = 0; // Промежуточная переменная для приведения данных
int16_t wheel_angle = 0; // Поворот руля
int16_t wheel_tilt = 0; // Наклон руля (вперед, назад)
int32_t accum = 0; // Заряд аккумулятора в %
int32_t accum_past = 0; // Прошлое значение фильтрованных данных с АЦП (для фильтра)

uint16_t pulse_count = 0; // Переменная обратного отсчета оставщихся импульсов (точнее смен состояния)

drive_mode_t drive_mode = CAR_MODE; // По-умолчанию режим автомобиля

BleGamepad bleGamepad("LeanDrive-BT", "Terrabyte", 50);

void setup() {
    // Выключаем WiFi
    WiFi.mode(WIFI_OFF);

    // Назначение кнопок
    pinMode(BTN_LEFT_CASE_PIN, INPUT_PULLUP);
    pinMode(BTN_RIGHT_CASE_PIN, INPUT_PULLUP);
    pinMode(BTN_LEFT_TOP_HANDLE_PIN, INPUT_PULLUP);
    pinMode(BTN_LEFT_BOTTOM_HANDLE_PIN, INPUT_PULLUP);
    pinMode(BTN_RIGHT_TOP_HANDLE_PIN, INPUT_PULLUP);
    pinMode(BTN_RIGHT_BOTTOM_HANDLE_PIN, INPUT_PULLUP);

    // Инициализация гироскопа
    Wire.begin();
    Wire.beginTransmission(MPU_ADDR);
    Wire.write(0x6B);  // PWR_MGMT_1 register
    Wire.write(0);     // set to zero (wakes up the MPU-6050)
    Wire.endTransmission(true);
    
    bleGamepad.begin(); // Инициализация Bluetooth
    bleGamepad.setAutoReport(false); // Устанавливаем ручное подтверждаение транзакции при помощи bleGamepad.sendReport();

    // Инициализируем ШИМ для генерации звуков
    ledcSetup(SOUND_CHANNEL, SOUND_FREQ, SOUND_RESOLUTION);
    ledcAttachPin(SOUND_PIN, SOUND_CHANNEL);


    
    // Измеряем напряжение аккумулятора путем усреднения по 20 отсчетам для снижения колебаний
    // Проводим калибровку уровней покоя педалей
    accum = 0;
    for (int i = 1; i <= 20; i++) {
        accum = accum + analogRead(ACCUM_PIN);
        accelerator_low_level = accelerator_low_level + analogRead(ACCELERATOR_PIN);
        break_low_level = break_low_level + analogRead(BREAK_PIN);
        delay(20); // Задержка для устранения переходных процессов
    }
    accum = accum / 20;
    accelerator_low_level = accelerator_low_level / 20 + 100;
    break_low_level = break_low_level / 20 + 100;
    accum_past = accum; // Установка начального значения заряда
    accum = constrain(accum, ACCUM_LOW_ADC, ACCUM_HI_ADC);
    accum = map(accum, ACCUM_LOW_ADC, ACCUM_HI_ADC, 1, 99);
    // Вычисляем, сколько раз пропикать
    pulse_count = (accum / 10) * 2; // Например, 65% - 6 раз (умножение на два - количество переходов в импульсах: один писк - два перехода: вкл и выкл)

//    Serial.begin(115200);
//  Serial.println("Starting...");  
  
}

void loop() {
    // Проверка клавиш переключения режима (если нажаты все 4 кнопки на баранке в течение 3 сек, то переводим режим автомобиль - самолет)
    if (!digitalRead(BTN_LEFT_TOP_HANDLE_PIN) && !digitalRead(BTN_LEFT_BOTTOM_HANDLE_PIN) && !digitalRead(BTN_RIGHT_TOP_HANDLE_PIN) && !digitalRead(BTN_RIGHT_BOTTOM_HANDLE_PIN)) {
        if (mode_time == 0) mode_time = millis(); // Если до этого не нажаты, то фиксируем время нажатия
        if ((millis() - mode_time) >= MODE_SWITCH_TIME) {
            // Переключаем режим
            if (drive_mode == CAR_MODE) {
                drive_mode = FLY_MODE;
                pulse_count = 4; // Пищим 2 раза
            } else {
               drive_mode = CAR_MODE;
               pulse_count = 2; // Пропищим разочек
            }
            // Сбросим время нажатия
            mode_time = 0;
        }
    } else {
      mode_time = 0; // Если хотя бы одна кнопка отжата, то сбрасываем время нажатия
    }
    
    // Проверка уровня заряда
    if ((millis() - last_accum_time) >= SOUND_INDICATOR_PERIOD) {
        last_accum_time = millis();
        if (accum < 20) pulse_count = SOUND_PULCES * 2; // Количество смен состояний в 2 раза больше количества импульсов       
    }
    
    // Обработка звука
    if (pulse_count > 0) {
        // Пора ли менять состояние звука
        if ((millis() - pulse_time) >= SOUND_PULSE_TIME) {
            pulse_time = millis();
            if ((pulse_count & 1) == 0) ledcWrite(SOUND_CHANNEL, SOUND_DUTY); // Включаем звук если число четное
            else ledcWrite(SOUND_CHANNEL, 0);
            pulse_count = pulse_count - 1;            
        }
    }
    
    // Пора ли опрашивать датчики
    if ((millis() - last_time) >= TIME_ROUND)
    {
        last_time = millis();
        // Чтение данных с гироскопа
        mpu_read();
        // Вычисляем поворот руля
        wheel_angle = -constrain(ay, -16380, 16380);
//                    Serial.print(ay);
//        Serial.print("   ");
//        Serial.print(wheel_angle);

        wheel_angle = map(wheel_angle, -16380, 16380, -32760, 32760);
        if (wheel_angle >= 32766) wheel_angle = 32760;
        if (wheel_angle <= -32766) wheel_angle = -32760;
//        Serial.print("   ");
//       Serial.println(wheel_angle);

        // Вычисляем наклон руля
        wheel_tilt = -constrain(ax, -16380, 16380);
        wheel_tilt = map(wheel_tilt, -16380, 16380, -32760, 32760);
        if (wheel_tilt >= 32766) wheel_tilt = 32760;
        if (wheel_tilt <= -32766) wheel_tilt = -32760;
        
        // Вычисление педали газа
        accelerator_value = analogRead(ACCELERATOR_PIN);
        accelerator_value = constrain(accelerator_value, accelerator_low_level, 4095);
        accelerator_value = map(accelerator_value, accelerator_low_level, 4095, 1, 65530); // Диапазон для триггеров
        if (accelerator_value < 1 ) accelerator_value = 1;
        if (accelerator_value > 65530 ) accelerator_value = 65530;        

        // Вычисление педали тормоза
        break_value = analogRead(BREAK_PIN);
        break_value = constrain(break_value, break_low_level, 4095);
        break_value = map(break_value, break_low_level, 4095, 1, 65530); // Диапазон для триггеров
        if (break_value < 1 ) break_value = 1;
        if (break_value > 65530 ) break_value = 65530;

        // Получение уровня заряда аккумулятора
        accum = analogRead(ACCUM_PIN);
        if (accum_past == 0) accum_past = accum; // Для первого раза
        accum = (accum_past * 9 + accum) / 10; // IIR фильтр (Сглаживает колебания по питанию). y(i) = y(i-1) * 0.9 + x(i) * 0.1, где  y - выходной сигнал, x - входной
        accum_past = accum;
        accum = constrain(accum, ACCUM_LOW_ADC, ACCUM_HI_ADC);
        accum = map(accum, ACCUM_LOW_ADC, ACCUM_HI_ADC, 1, 99);

        // Если руль подключен к компьютеру, передаем данные
        if(bleGamepad.isConnected())
        {
            // Установка наклона руля (левый стик)
            bleGamepad.setLeftThumb(wheel_angle, wheel_tilt);
            
            // Установка акселератора и тормоза в зависимости от режима
            if (drive_mode == CAR_MODE) {
                // В режиме автомобиля управление через триггеры
                bleGamepad.setRightTrigger(accelerator_value);
                bleGamepad.setLeftTrigger(break_value);
            } else {
                // В режиме самолета управление через одну ось правого стика
                accelerator_value = (accelerator_value - break_value) / 2;
//                Serial.print(accelerator_value);
//                Serial.print("   ");
                bleGamepad.setRightThumb(accelerator_value, 0);
            }
//            bleGamepad.setRightThumb(accelerator_value, break_value); // Управление газом-тормозом через стики
//            bleGamepad.setBatteryLevel(accum); // С этим проблемы. Геймпад видно, но реакции на кнопки нет (joy.cpl не видит). Надо разбираться.
            
            // Реакция на кнопки
            if (!digitalRead(BTN_LEFT_CASE_PIN)) bleGamepad.press(BUTTON_1);
            else bleGamepad.release(BUTTON_1);           
            if (!digitalRead(BTN_RIGHT_CASE_PIN)) bleGamepad.press(BUTTON_2);
            else bleGamepad.release(BUTTON_2);
            if (!digitalRead(BTN_LEFT_TOP_HANDLE_PIN)) bleGamepad.press(BUTTON_3);
            else bleGamepad.release(BUTTON_3);           
            if (!digitalRead(BTN_LEFT_BOTTOM_HANDLE_PIN)) bleGamepad.press(BUTTON_4);
            else bleGamepad.release(BUTTON_4);
            if (!digitalRead(BTN_RIGHT_TOP_HANDLE_PIN)) bleGamepad.press(BUTTON_5);
            else bleGamepad.release(BUTTON_5);           
            if (!digitalRead(BTN_RIGHT_BOTTOM_HANDLE_PIN)) bleGamepad.press(BUTTON_6);
            else bleGamepad.release(BUTTON_6);
            
            // Передаем данные
            bleGamepad.sendReport();      
        }
    }
}

// Чтение данных с гироскопа
void mpu_read(){
    Wire.beginTransmission(MPU_ADDR);
    Wire.write(0x3B);  // начинаем с регистра 0x3B (ACCEL_XOUT_H)
    Wire.endTransmission(false);
    Wire.requestFrom(MPU_ADDR,14,true);  // запросить всего 14 регистров
    ax=Wire.read()<<8|Wire.read();  // 0x3B (ACCEL_XOUT_H) & 0x3C (ACCEL_XOUT_L)
    ay=Wire.read()<<8|Wire.read();  // 0x3D (ACCEL_YOUT_H) & 0x3E (ACCEL_YOUT_L)
    az=Wire.read()<<8|Wire.read();  // 0x3F (ACCEL_ZOUT_H) & 0x40 (ACCEL_ZOUT_L)
    temperature=Wire.read()<<8|Wire.read();  // 0x41 (TEMP_OUT_H) & 0x42 (TEMP_OUT_L)
    gx=Wire.read()<<8|Wire.read();  // 0x43 (GYRO_XOUT_H) & 0x44 (GYRO_XOUT_L)
    gy=Wire.read()<<8|Wire.read();  // 0x45 (GYRO_YOUT_H) & 0x46 (GYRO_YOUT_L)
    gz=Wire.read()<<8|Wire.read();  // 0x47 (GYRO_ZOUT_H) & 0x48 (GYRO_ZOUT_L)
 }
Изображения
Внешний вид.
IMG_20210326_124242.jpg

Верхняя крышка.
IMG_20210312_143356.jpg

Нижняя крышка.
IMG_20210313_120031.jpg

Начинка.
IMG_20210320_112434.jpg

Материнская плата сверху, вид со стороны контроллера.
IMG_20210316_161513.jpg

Материнская плата в сборе, вид снизу, со стороны аккумулятора.
IMG_20210318_211105.jpg

Блок оптических датчиков в сборе.
IMG_20210318_202151.jpg

Буду признателен за любые комментарии, замечания, пожелания!

Спасибо Александру за ценнейший ресурс!

P.S. 30.03.2021 Версия 1.1: добавления, исправления
 
Изменено:

scientificman

✩✩✩✩✩✩✩
28 Мар 2021
3
4
Обновил сегодня код. Добавлена автоматическая калибровка педалей и исправлено некорректное озвучивание заряда.
Похоже, что первый пост править нежелательно, т.к. сообщение ушло на премодерацию. Буду выкладывать обновления в последующих сообщениях.
 

IamNikolay

★★★✩✩✩✩
15 Янв 2020
820
175
размером и цветом напоминает детский руль, кучу пластика и времени сэкономили бы, если купили бы его и заменили внутренности на свои.
что касается самой темы геймпадов, то направление быстро развивается и кнопок на них все больше, так что задумайтесь.
еще, для универсальности, можно добавить пару аналоговых джойстиков.
 

Вложения

  • Лойс +1
Реакции: scientificman

scientificman

✩✩✩✩✩✩✩
28 Мар 2021
3
4
Спасибо за Ваше мнение!
Задачи делать универсальное устройство нет. Это тема хорошо отработана. Представляю, сколько инженеров разрабатывали тот же Box-овский или PS-ный контроллер. Устройство скорее для фанатов!