Arduino

Джойстик двухосевой: считывание аналоговых осей и кнопки

Введение

Двухосевой джойстик из комплекта — это, по сути, два потенциометра (по одному на каждую ось X/Y), механически связанные с одной рукояткой, плюс отдельная кнопка, срабатывающая при нажатии рукоятки вниз. В статье разберём считывание положения джойстика и передачу этих данных в Qt-приложение для управления, например, направлением движения или позицией курсора.

Концепция

Каждая ось джойстика подключается к аналоговому пину и считывается через analogRead(), возвращающий значение от 0 до 1023 — в покое (рукоятка в центре) значение обычно близко к середине диапазона (около 512), отклоняясь к 0 или 1023 при отклонении рукоятки в соответствующую сторону. Кнопка джойстика подключается к цифровому пину и считывается как обычная кнопка, обычно с внутренним подтягивающим резистором (INPUT_PULLUP), поскольку нажатие замыкает контакт на землю.

Пример кода

// firmware/src/main.cpp — считывание джойстика и отправка нормализованных значений
#define JOYSTICK_X_PIN A1
#define JOYSTICK_Y_PIN A2
#define JOYSTICK_BUTTON_PIN 3

void setup()
{
    Serial.begin(9600);
    pinMode(JOYSTICK_BUTTON_PIN, INPUT_PULLUP);
    delay(100);
    Serial.println("READY");
}

void loop()
{
    static unsigned long lastSendTime = 0;
    static bool lastButtonState = HIGH;

    if (millis() - lastSendTime >= 100) { // 10 раз в секунду — достаточно для отзывчивого управления
        const int rawX = analogRead(JOYSTICK_X_PIN);
        const int rawY = analogRead(JOYSTICK_Y_PIN);

        // Нормализация к диапазону -100..100 с компенсацией центрального смещения (около 512)
        const int normalizedX = map(rawX, 0, 1023, -100, 100);
        const int normalizedY = map(rawY, 0, 1023, -100, 100);

        Serial.print("JOYSTICK:");
        Serial.print(normalizedX);
        Serial.print(",");
        Serial.println(normalizedY);

        lastSendTime = millis();
    }

    // Кнопка проверяется отдельно, чаще, и только сообщение об изменении состояния (не постоянно)
    const bool currentButtonState = digitalRead(JOYSTICK_BUTTON_PIN);
    if (currentButtonState != lastButtonState) {
        Serial.println(currentButtonState == LOW ? "JOYSTICK:BUTTON:PRESSED" : "JOYSTICK:BUTTON:RELEASED");
        lastButtonState = currentButtonState;
        delay(20); // простейший дебаунс — игнорируем "дребезг" контакта сразу после изменения
    }
}

Пояснения к коду

map(rawX, 0, 1023, -100, 100) — встроенная функция Arduino для линейного масштабирования значения из одного диапазона в другой, здесь применяемая для перевода «сырых» показаний АЦП (0–1023) в более интуитивный, центрированный около нуля диапазон (-100..100), что удобнее использовать на стороне Qt-приложения для определения направления и силы отклонения джойстика без необходимости в дополнительных вычислениях.

Обработка кнопки построена иначе, чем считывание осей — вместо постоянной периодической отправки текущего состояния кнопки (что было бы избыточным трафиком для значения, которое меняется редко) код отслеживает именно факт изменения состояния (currentButtonState != lastButtonState) и отправляет сообщение только в момент нажатия или отпускания, с простым дебаунсом через короткую delay(20) сразу после обнаруженного изменения — это защищает от множественных, ложных срабатываний из-за механического «дребезга» контакта кнопки в момент физического нажатия.

Подводные камни

  • Использование delay(20) для дебаунса кнопки джойстика внутри loop(), где также происходит обработка входящих команд и считывание осей, блокирует выполнение остальной части цикла на эти 20 миллисекунд при каждом изменении состояния кнопки — для большинства практических случаев это допустимо (поскольку 20 мс — небольшая, единичная задержка, происходящая нечасто), но для проектов с очень высокими требованиями к отзывчивости стоит реализовать дебаунс через неблокирующий таймер на основе millis(), аналогично остальной логике прошивки.
  • Центральное положение джойстика в реальности редко точно равно 512 (середина диапазона АЦП) — механический разброс производства потенциометров означает, что «нейтральное» положение может соответствовать, например, 498 или 530, что при прямом масштабировании через map() без калибровки даёт небольшое, но заметное ненулевое значение даже когда рукоятка физически находится в центре — для более точного управления стоит реализовать процедуру калибровки (считывание реального центрального значения при старте) вместо предположения идеального центра в 512.
  • Слишком частая отправка показаний осей джойстика (выше нескольких десятков раз в секунду) создаёт избыточный трафик через последовательный порт без реальной выгоды для отзывчивости управления — человеческое восприятие плавности управления не требует частоты обновления выше определённого порога (обычно 10-20 раз в секунду вполне достаточно для комфортного управления), и более частая отправка только увеличивает нагрузку на канал связи без заметного улучшения пользовательского опыта.
  • Отсутствие «мёртвой зоны» (dead zone) вблизи центрального положения — даже у качественного джойстика небольшие, неизбежные колебания значения АЦП вблизи нейтрального положения (механическая вибрация, электрический шум) могут восприниматься Qt-приложением как небольшое, но постоянно «дрожащее» отклонение, даже когда пользователь физически не трогает джойстик — типичное решение: явно игнорировать (приравнивать к нулю) значения в небольшом диапазоне вокруг центра (например, -5..5 из нормализованного диапазона -100..100), а не передавать любое, даже минимальное отклонение как значимый сигнал управления.