Joystik для ПК на базе Arduino

в 18:39, , рубрики: c++

Идея

Недавно купил длинный HDMI-кабель для просмотра киношек на телевизоре с комфортной кровати, а не сидя за ПК в углу.

Возникла проблема: часто вставать и ходить до ПК, чтобы поставить видео на паузу или изменить настройки плеера (качество, звук, озвучка). Захотелось чего-то компактного и простого, наподобие «Плей‑Пауза» пульта. Был вариант просто купить дешёвый Bluetooth‑комплект мышь‑клавиатура или только беспроводную мышь, Но это показалось мне неинтересным и простым.

Было принято решение разработать специфическое устройство для этих задач.

Проектирование

Во-первых, определим, какие функции и, как следствие, устройства ввода необходимы.

  1. В качестве устройства, позволяющего не только выполнять разовый «клик» по кнопке паузы, но и имеющего возможность передвигать курсор мыши, был выбран стик KY-023, наподобие тех, что применяются в контроллерах DualShock для PS4.

    Joystick KY-023

    Joystick KY-023

    KY-023 имеет:

    1) 2 аналоговых выхода: Ось X, Ось Y;
    2) 1 дискретный выход: Кнопка самого «стика».

    Т. к. проект в процессе разработки стал расширяться и усложняться, приходя к полноценному и большему функционалу, чем у компьютерной мыши, было решено добавить второй «стик».

    Таким образом получили конструкцию со следующим функционалом: правый «стик» должен отвечать за перемещение курсора (подобно как правый стик джойстика от «плойки» управляет обзором персонажа в играх); Левый стик должен будет управлять громкостью плеера и перемоткой видео (пока не реализован, в процессе разработки функций); внутренние кнопки «стиков» копируют ЛКМ и ПКМ.

  2. Т. к. только начинаю заниматься программированием, было решено использовать как контроллер плату Arduino. Почитав про особенности «камней», применяемых в «разношерстных» платформах, я выбрал это:

    Joystik для ПК на базе Arduino - 2

    Arduino pro mini на базе «камня» AtMega 168 (5V, 16MHz) в роли передатчика;

    Joystik для ПК на базе Arduino - 3

    Arduino pro micro на базе «камня» AtMega 23u4 в роли приемника и устройства, имеющего свойство двустороннего общения с ПК, определяется как HID-устройство (клавиатура, мышь).

  3. Необходимо было устройство приема‑передачи информации между платами Arduino. Выбрал радиомодули NRF24L01, работающие в диапазоне частот 2.4–2.5 ГГц. Также в связи с особенностями питания «камня» NRF потребовался адаптер со стабилизатором напряжения, способный использовать внешнее питание от 4.8V до 12V и подавать на плату NRF 3.3V.

    NRF24L01
    NRF24L01

    Немного характеристик:
    Напряжение питания: 1,9В – 3,6В;
    Интерфейс обмена данными: SPI;
    Частота приёма и передачи: 2,4 ГГц;
    Количество каналов: 128 с шагом 1МГц;
    Тип модуляции: GFSK;

    NRF24L01

    NRF24L01

    Скорость передачи данных: 250kbps, 1Mbps и 2Mbps;
    Чувствительность приёмника: -82 dBm;
    Расстояние приёма/передачи данных: 100м — прямая видимость; 30м — помещение;
    Коэффициент усиления антенны: 2dBm;
    Диапазон рабочей температуры: -40оС…+85оС;
    Организация сети на одном канале: 7 модулей (1 приёмник и 6 передатчиков).

Электросхема

На схеме не указан адаптер, но распиновка NRF и адаптера идентичные, и все необходимые сигналы прописаны в литографии на маске плат.

TX

TX
RX

RX

Программа

  1. Перво-наперво необходимо было научиться считывать и обрабатывать сигналы со стиков и управлять курсором напрямую с Arduino Pro Micro.

    #include <Mouse.h>
    
    const int X1_Pin = A0;
    const int Y1_Pin = A1;
    const int X2_Pin = A2;
    const int Y2_Pin = A3;
    
    const int SW1_Pin = 3;
    const int SW2_Pin = 2;
    
    int SW1_Stage;
    int SW2_Stage;
    
    int SW1;
    int SW2;
    
    const int Sp = 5;
    
    void setup() {
      Serial.begin(9600);
    
      Mouse.begin();
    
      pinMode(SW1_Pin, INPUT_PULLUP);
      pinMode(SW2_Pin, INPUT_PULLUP);
      pinMode(8, OUTPUT);
      pinMode(9, OUTPUT);
    
    }
    
    void loop() {
      //Доп пины питания
      digitalWrite(8, HIGH);
      digitalWrite(9, HIGH);
    
      //Чтение портов
      int x1 = analogRead(X1_Pin);
      int y1 = analogRead(Y1_Pin);
      int x2 = analogRead(X2_Pin);
      int y2 = analogRead(Y2_Pin);
    
      SW1_Stage = digitalRead(SW1_Pin);
      SW2_Stage = digitalRead(SW2_Pin);
    
      int x1pos, y1pos;
      int x2pos, y2pos;
    
      //Фильтр XY1
      if (x1 > 450 and x1 < 550)
        x1pos = 0;
      if (x1 >= 550)
        x1pos = map(x1, 550, 1023, 0, Sp);
      if (x1 <= 450)
        x1pos = map(x1, 450, 0, 0, -Sp);
      if (y1 > 450 and y1 < 550)
        y1pos = 0;
      if (y1 >= 550)
        y1pos = map(y1, 550, 1023, 0, Sp);
      if (y1 <= 450)
        y1pos = map(y1, 450, 0, 0, -Sp);
    
      //Обработка кнопки ЛКМ
      if (SW1_Stage == LOW)
        SW1 = 1;
      else
        SW1 = 0;
    
      //Фильтр XY2
      if (x2 > 450 and x2 < 550)
        x2pos = 0;
      if (x2 >= 550)
        x2pos = map(x2, 550, 1023, 0, Sp);
      if (x2 <= 450)
        x2pos = map(x2, 450, 0, 0, -Sp);
      if (y2 > 450 and y2 < 550)
        y2pos = 0;
      if (y2 >= 550)
        y2pos = map(y2, 550, 1023, 0, Sp);
      if (y2 <= 450)
        y2pos = map(y2, 450, 0, 0, -Sp);
    
      //Обработка кнопки ПКМ
      if (SW2_Stage == LOW)
        SW2 = 1;
      else
        SW2 = 0;
    
      //Управление курсором
      Mouse.move(x1pos, y1pos);
    
      if (SW1) {
        Mouse.press(MOUSE_LEFT);
      } else {
        Mouse.release(MOUSE_LEFT);
      }
      if (SW2) {
        Mouse.press(MOUSE_RIGHT);
      } else {
        Mouse.release(MOUSE_RIGHT);
      }
      /*/Отладка
        Serial.print(x1pos);
        Serial.print(":");
        Serial.print(y1pos);
        Serial.print(":");
        Serial.println(SW1_Stage);
        Serial.print(":");
        Serial.print(x2pos);
        Serial.print(":");
        Serial.print(y2pos);
        Serial.print(":");
        Serial.println(SW2_Stage);
       /*/
      delay(10);
    
    }
  2. Учимся «дружить» NRF‑ки и моргать лампочкой по нажатию кнопки.

    TX:

    #include <SPI.h>
    #include "nRF24L01.h"
    #include "RF24.h"
    
    RF24 radio(9, 10);
    byte address[][6] = {"1Node", "2Node", "3Node", "4Node", "5Node", "6Node"};
    byte button = 3;
    byte transmit_data[1];
    byte latest_data[1];
    boolean flag;
    void setup() {
      Serial.begin(9600);
    
      pinMode(button, INPUT_PULLUP);
      radio.begin();
      radio.setAutoAck(1);
      radio.setRetries(0, 15);
      radio.enableAckPayload();
      radio.setPayloadSize(32);
      radio.openWritingPipe(address[0]);
      radio.setChannel(0x60);
      radio.setPALevel (RF24_PA_MAX);
      radio.setDataRate (RF24_250KBPS);
      radio.powerUp();
      radio.stopListening();
    }
    
    void loop() {
      transmit_data[0] = !digitalRead(button);
    
      for (int i = 0; i < 3; i++) {
        if (transmit_data[i] != latest_data[i]) {
          flag = 1;
          latest_data[i] = transmit_data[i];
        }
      }
    
      if (flag == 1) {
        radio.powerUp();
        radio.write(&transmit_data, sizeof(transmit_data));
        flag = 0;
        radio.powerDown();
      }
    }

    RX:

    #include <SPI.h>
    #include "nRF24L01.h"
    #include "RF24.h"
    #include <Servo.h>
    
    RF24 radio(9, 10);
    byte recieved_data[1];
    byte L = 13;
    byte address[][6] = {"1Node", "2Node", "3Node", "4Node", "5Node", "6Node"};
    
    void setup() {
      Serial.begin(9600);
      pinMode(L, OUTPUT);
      radio.begin();
      radio.setAutoAck(1);
      radio.setRetries(0, 15);
      radio.enableAckPayload();
      radio.setPayloadSize(32);
      radio.openReadingPipe(1, address[0]);
      radio.setChannel(0x60);
      radio.setPALevel (RF24_PA_MAX);
      radio.setDataRate (RF24_250KBPS);
      radio.powerUp();
      radio.startListening();
    }
    
    void loop() {
      byte pipeNo;
      while ( radio.available(&pipeNo)) {
        radio.read(&recieved_data, sizeof(recieved_data));
        digitalWrite(L, recieved_data[0]);
      }
    }
  3. А теперь самое интересное! Объединить эти два кода. Не обошлось и без плясок с бубном, и убегающим в «самоволку» курсором.

    TX:

    #include <SPI.h>
    #include "nRF24L01.h"
    #include "RF24.h"
    
    RF24 radio(9, 10);        //Создать модуль на пинах 9 и 10
    
    byte address[][6] = {"1Node", "2Node", "3Node", "4Node", "5Node", "6Node"};   //возможные номера труб
    
    const int XL_Pin = A0;    //Аналоговый вход левого стика ось X
    const int YL_Pin = A1;    //Аналоговый вход левого стика ось Y
    const int XR_Pin = A2;    //Аналоговый вход правого стика ось X
    const int YR_Pin = A3;    //Аналоговый вход правогоо стика ось Y
    byte LBM_Pin = 2;         //Цифровой вход ЛКМ
    byte RBM_Pin = 3;         //Цифровой вход ПКМ
    
    byte transmit_data[6];    //Массив, хранящий передаваемые данные
    byte latest_data[6];      //Массив, хранящий последние переданные данные
    
    boolean flag;             //Флаг отправки данных
    
    void setup() {
      Serial.begin(9600);               //Открываем порт для связи с ПК
    
      pinMode(XL_Pin, INPUT);           //Настройка порта левого стика ось X
      pinMode(YL_Pin, INPUT);           //Настройка порта левого стика ось Y
      pinMode(XR_Pin, INPUT);           //Настройка порта правого стика ось X
      pinMode(YR_Pin, INPUT);           //Настройка порта правого стика ось Y
      pinMode(LBM_Pin, INPUT_PULLUP);   //Настройка порта ЛКМ
      pinMode(RBM_Pin, INPUT_PULLUP);   //Настройка порта ЛКМ
    
      pinMode(7, OUTPUT);               //Доп питание
      pinMode(8, OUTPUT);               //Доп питание
      digitalWrite(7, HIGH);            //Доп пин питания левого стика
      digitalWrite(8, HIGH);            //Доп пин питания правого стика
    
      radio.begin();                        //Активировать модуль
      radio.setAutoAck(1);                  //Режим подтверждения приёма, 1 вкл 0 выкл
      radio.setRetries(0, 15);              //Время между попыткой достучаться, число попыток
      radio.enableAckPayload();             //Разрешить отсылку данных в ответ на входящий сигнал
      radio.setPayloadSize(32);             //Размер пакета, в байтах
      radio.openWritingPipe(address[0]);    //Труба 0, открыть канал для передачи данных
      radio.setChannel(0x70);               //Выбираем канал (в котором нет шумов!)
    
      radio.setPALevel (RF24_PA_MAX);       //Уровень мощности передатчика. На выбор RF24_PA_MIN, RF24_PA_LOW, RF24_PA_HIGH, RF24_PA_MAX
      radio.setDataRate (RF24_250KBPS);     //Скорость обмена. На выбор RF24_2MBPS, RF24_1MBPS, RF24_250KBPS должна быть одинакова на приёмнике и передатчике!при самой низкой скорости имеем самую высокую чувствительность и дальность!!
    
      radio.powerUp();                      //Начать работу
      radio.stopListening();                //Не слушаем радиоэфир, мы передатчик
    
    }
    
    void loop() {
    
      //Чтение портов
    
      transmit_data[0] = map(analogRead(XL_Pin), 0, 1023, 0, 255);    //Записать значение XL на 1 место в массиве
      transmit_data[1] = map(analogRead(YL_Pin), 0, 1023, 0, 255);    //Записать значение YL на 2 место в массиве
      transmit_data[2] = map(analogRead(XR_Pin), 0, 1023, 0, 255);    //Записать значение XR на 3 место в массиве
      transmit_data[3] = map(analogRead(YR_Pin), 0, 1023, 0, 255);    //Записать значение YR на 4 место в массиве
      transmit_data[4] = !digitalRead(LBM_Pin);                       //Записать сигнал ЛКМ на 5 место в массиве
      transmit_data[5] = !digitalRead(RBM_Pin);                       //Записать сигнал ПКМ на 5 место в массиве
    
      radio.powerUp();                                      // включить передатчик
      radio.write(&transmit_data, sizeof(transmit_data));   // Отправить по радио
      for (int i = 0; i < 6; i++) {                         // В цикле от 0 до числа каналов
        if (transmit_data[i] != latest_data[i]) {           // Если есть изменения в transmit_data
          flag = 1;                                         // Поднять флаг отправки по радио
          latest_data[i] = transmit_data[i];                // Запомнить последнее изменение
        }
      }
    
      if (flag == 1) {
        radio.powerUp();                                      // Включить передатчик
        radio.write(&transmit_data, sizeof(transmit_data));   // Отправить по радио
        flag = 0;                                             // Опустить флаг
        radio.powerDown();                                    // Выключить передатчик
      }
      /*/Отладка, проверка сигнала на A0,A1,A2,A3
          Serial.print(analogRead(XL_Pin));
          Serial.print(":");
          Serial.print(analogRead(YL_Pin));
          Serial.print(":");
          Serial.println(!digitalRead(2));
          Serial.print("n");
          Serial.print(analogRead(XR_Pin));
          Serial.print(":");
          Serial.print(analogRead(YR_Pin));
          Serial.print(":");
          Serial.println(!digitalRead(3));
          delay(10);
      */
    
    }
    //Список занятых пинов: A0,A1,A2,A3,1,2,3,4,7,8,9,10,11,12,13
    //Список передаваемых пинов: A0,A1,A2,A3,2,3
    //Список доп пинов питания 7,8 (В итоговой версии необходимо объединить на 5V, Gnd объединить)

    RX:

    #include <SPI.h>
    #include "nRF24L01.h"
    #include "RF24.h"
    #include <Mouse.h>
    
    RF24 radio(9, 10);      //Создать модуль на пинах 9 и 10
    
    byte recieved_data[6];  //Массив принятых данных
    
    byte XLP;               //Значения левого стика ось X
    byte YLP;               //Значения левого стика ось Y
    byte XRP;               //Значения правого стика ось X
    byte YRP;               //Значения правого стика ось Y
    int LBMP;               //Значения ЛКМ
    int RBMP;               //Значения ПКМ
    
    const int Sp = 30;      //Скорость курсора (10,20,30,40,50,60,70) чем больше, тем медленне
    
    byte address[][6] = {"1Node", "2Node", "3Node", "4Node", "5Node", "6Node"}; //возможные номера труб
    
    void setup() {
      Serial.begin(9600);   //Открываем порт для связи с ПК
    
      Mouse.begin();
    
      radio.begin();  //Активировать модуль
      radio.setAutoAck(1);                    // Режим подтверждения приёма, 1 вкл 0 выкл
      radio.setRetries(0, 15);                // Время между попыткой достучаться, число попыток)
      radio.enableAckPayload();               // Разрешить отсылку данных в ответ на входящий сигнал
      radio.setPayloadSize(32);               // Размер пакета, в байтах
      radio.openReadingPipe(1, address[0]);   // Слушаем трубу 0
      radio.setChannel(0x70);                 // Выбираем канал (в котором нет шумов!)
    
      radio.setPALevel (RF24_PA_MAX);         // Уровень мощности передатчика. На выбор RF24_PA_MIN, RF24_PA_LOW, RF24_PA_HIGH, RF24_PA_MAX
      radio.setDataRate (RF24_250KBPS);       // Скорость обмена. На выбор RF24_2MBPS, RF24_1MBPS, RF24_250KBPS должна быть одинакова на приёмнике и передатчике! Gри самой низкой скорости имеем самую высокую чувствительность и дальность!!
    
      radio.powerUp();                        // Начать работу
      radio.startListening();                 // Начинаем слушать эфир, мы приёмный модуль
    
    }
    
    void loop() {
      byte pipeNo;
      while ( radio.available(&pipeNo)) {                     // Есть входящие данные
        radio.read(&recieved_data, sizeof(recieved_data));    // Читаем входящий сигнал
    
        XLP = recieved_data[0];   //Читаем входящие данные оси X левого стика и записываем значение
        YLP = recieved_data[1];   //Читаем входящие данные оси Y левого стика и записываем значение
        XRP = recieved_data[2];   //Читаем входящие данные оси X правого стика и записываем значение
        YRP = recieved_data[3];   //Читаем входящие данные оси Y правого стика и записываем значение
        LBMP = recieved_data[4];  //Читаем входящие данные ЛКМ и записываем значение
        RBMP = recieved_data[5];  //Читаем входящие данные ПКМ и записываем значение
    
      }
    
      int xLpos, yLpos;
      int xRpos, yRpos;
    
      //Фильтр XYL
      if (XLP > 120 and XLP < 130)
        xLpos = 0;
      if (XLP >= 130)
        xLpos = map(XLP, 130, 255, 0, 80);
      if (XLP <= 120)
        xLpos = map(XLP, 120, 0, 0, -80);
    
      if (YLP > 120 and YLP < 130)
        yLpos = 0;
      if (YLP >= 130)
        yLpos = map(YLP, 130, 255, 0, 80);
      if (YLP <= 120)
        yLpos = map(YLP, 120, 0, 0, -80);
    
      //Фильтр XYR
      if (XRP > 120 and XRP < 130)
        xRpos = 0;
      if (XRP >= 130)
        xRpos = map(XRP, 130, 255, 0, 80);
      if (XRP <= 120)
        xRpos = map(XRP, 120, 0, 0, -80);
    
      if (YRP > 120 and YRP < 130)
        yRpos = 0;
      if (YRP >= 130)
        yRpos = map(YRP, 130, 255, 0, 80);
      if (YRP <= 120)
        yRpos = map(YRP, 120, 0, 0, -80);
    
      //Управление курсором
      Mouse.move(xRpos / Sp, yRpos / Sp);
    
      if (LBMP) {
        Mouse.press(MOUSE_LEFT);
      } else {
        Mouse.release(MOUSE_LEFT);
      }
      if (RBMP) {
        Mouse.press(MOUSE_RIGHT);
      } else {
        Mouse.release(MOUSE_RIGHT);
      }
      /*//Отладка A0,A1,A2,A3
        Serial.print(XLP);
        Serial.print(":");
        Serial.print(YLP);
        Serial.print(":");
        Serial.println(LBMP);
        Serial.print("n");
        Serial.print(XRP);
        Serial.print(":");
        Serial.print(YRP);
        Serial.print(":");
        Serial.println(RBMP);
        Serial.print("n");
        Serial.print(xRpos);
        Serial.print(":");
        Serial.print(yRpos);
        delay(5);
      */
    }
    //Список занятых пинов: 9,10,14,15,16
    //Список передаваемых пинов: A0,A1,A2,A3,2,3
    
    //LBM-LeftButtonMouse(ЛКМ) RBM-RightButtonMouse(ПКМ)

Прототипирование

В обоих случаях последовательность действий при подключении радиомодулей одинакова: от адаптера питания NRF отпаиваем колодку 6pin(F) и припаиваем напрямую NRF гребёнкой 6pin(M), короткими проводами припаиваемся к платам Arduino. Аккуратно и компактно складываем и сжимаем «бутерброд». ПОЛЕЗНО! Вокруг антенны для каждого модуля NRF намотать пару‑тройку витков монтажного провода (в моём случае 0.2mm^2). Загоняем все в широкую термоусадку и фиксируем.

Для передатчика припаиваем «стики» на удобном вам расстоянии и также все фиксируем термоусадкой.

RX

RX
RX

RX
TX (Joystick)

TX (Joystick)
TX (Joystick)

TX (Joystick)

Итог

Получился рабочий прототип GamePad, похожий на DualShock.

Баги

ВАЖНО! Сначала подключать к питанию передатчик, после — приемник к ПК. Т.к. имеется баг в виде убегающего курсора при обратном порядке подключения, но он сразу же подчиняется, как только подаёшь питание на передатчик.

Высокая чувствительность стиков и резкое перемещение курсора (сложно попадать по кнопкам диалоговых окон).

Дальнейшее развитие

  1. Сейчас в разработке корпус для данного прототипа.

  2. Также требуется создать систему питания на основе аккумулятора 18 650 с модулями заряда и преобразователя напряжения до 5V.

  3. В планах добавить энкодер для имитации колёсика мыши; ряд тактовых кнопок с различными функциями, расширить код — придать действия для осей левого стика.

Заметки

Буду рад вашим предложениям по улучшению и развитию функционала данного девайса. В частности, идеям исправления скорости курсора, придания ему плавности.

Автор:
SamDrankRaven

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js