




여러분, 안녕하세요. 다시 찾아주셔서 감사합니다.
이것은 Arduino Nano ESP32를 기반으로 제작한 무선 블루투스 게임 컨트롤러의 프로토타입입니다.
이 프로젝트의 취지는 전자 부품과 PCB부터 외장 케이스, 펌웨어에 이르기까지 모든 요소를 완전히 오픈소스로 공개하여, 누구나 자신만의 게임 컨트롤러를 제작하거나 개조할 수 있게 하고, 이 설계를 바탕으로 자신만의 프로젝트를 시작할 수 있도록 하는 것입니다.
이 시제품은 그 목표를 향한 첫걸음입니다. 최종 하드웨어를 처음부터 설계하기 전에 핵심 전자 부품을 테스트하고, 맞춤형 컨트롤러가 무선으로 작동하는지 확인하고 싶었습니다.
시제품을 만들 때는 제가 이전에 제작한 유선 게임 컨트롤러 의 PCB를 재사용했습니다. 그 보드는 원래 Arduino Pro Micro와 ATmega32U4를 기반으로 설계되었습니다. Pro Micro를 Arduino Nano ESP32로 교체하면 ESP32-S3에 내장된 Bluetooth Low Energy를 활용하여 동일한 기본 컨트롤러 하드웨어를 무선 게임패드로 바꿀 수 있습니다.
이 시제품은 이미 완전히 작동하며, Bluetooth를 통해 컴퓨터에 연결해 일반 게임 컨트롤러처럼 사용할 수 있습니다. 하드웨어와 펌웨어를 테스트하는 동안 이 컨트롤러로 다음과 같은 게임을 플레이해 왔습니다. Cyberpunk 2077 및 NieR .
이것은 아직 최종 컨트롤러가 아닙니다. 최종 버전에는 완전히 맞춤 설계된 PCB와 용도에 맞게 제작된 케이스가 적용되며, 디자인도 훨씬 더 세련되게 다듬어질 것입니다. 현재로서는 이 시제품을 통해 개념이 작동한다는 것을 입증하고 오픈소스 컨트롤러 프로젝트의 기반을 마련하는 것이 목적입니다.
이 글에서는 이 시제품의 전체 제작 과정을 다루고 있으니, 이제 제작을 시작해 보겠습니다.
STEP 1. 준비물
이번 조립에 사용된 부품들은 다음과 같습니다.
- 맞춤형 PCB (이전 프로젝트에서 회수한 것)
- 아두이노 나노 ESP32 보드
- 직각 푸시 버튼
- 가로형 푸시 버튼
- 아날로그 조이스틱
- 휴대용 충전기
- USB-C 케이블
- 양면 테이프
STEP 2. 하드웨어 - 아두이노 게임 컨트롤러


아두이노 나노 ESP32가 이 프로젝트의 ‘두뇌’라면, 앞서 제작한 게임 컨트롤러 PCB는 단연코 ‘근력’이라 할 수 있습니다.
이번 프로젝트에서는 이전 아두이노 게임 컨트롤러 프로젝트에서 사용했던 메인 조이스틱 및 버튼 PCB를 재사용하고 있습니다. 원본 버전에서는 아두이노 프로 마이크로를 이 PCB에 연결하여 컨트롤러와 PC 간의 인터페이스 역할을 하게 했습니다. ATmega32U4의 기본 USB HID 지원 기능을 활용하여 게임을 제어하고, 이 보드를 일반 게임 컨트롤러로 사용할 수 있었습니다.
하지만 이 컨트롤러에서 가장 흥미로운 점은 보드에 있는 10개의 버튼을 모두 제어하는 데 단 하나의 I/O 핀만 사용된다는 것입니다. 저는 기발한 저항 분압 회로 구성을 활용하여 이를 구현했는데, 이를 통해 마이크로컨트롤러가 단일 아날로그 핀에서 읽은 서로 다른 전압 레벨을 바탕으로 어떤 버튼이 눌렸는지 식별할 수 있습니다. 다음 단계에서 이 원리에 대해 설명하겠습니다.
또한 이 PCB를 설계할 때 두 개의 썸스틱을 장착할 수 있도록 여유를 두었는데, 이번 무선 버전에서 그 장점을 잘 활용할 수 있었습니다.
원본 컨트롤러의 구조와 그 외 설계 결정 사항에 대해 더 자세히 알아보려면 해당 프로젝트 페이지를 확인해 보세요.
https://www.instructables.com/Arduino-Retro-Game-Controller
STEP 3. PCB 설계



이 프로젝트에서는, 원래 버전이 유선 게임 컨트롤러로 설계되었기 때문에, HID를 지원하는 마이크로컨트롤러의 GPIO 핀에 버튼이 연결된, 꽤 전통적이고 단순한 게임 컨트롤러 PCB를 제작했습니다.
흥미로운 점은 단 하나의 I/O 핀으로 어떻게 10개의 버튼을 연결해 냈는지입니다.
이를 위해 VCC와 버튼 입력 단자 사이에 1 kΩ 저항 12개를 직렬로 연결한 저항 사다리 회로를 구성했습니다. 각 버튼은 저항 사다리의 서로 다른 지점에 연결되어 있으며, 각 스위치의 다른 쪽 끝은 접지에 연결되어 있습니다.
버튼이 눌리면, 저항 사다리 회로 내에서의 위치에 따라 공통 아날로그 입력 단자에 서로 다른 전압이 발생합니다. 마이크로컨트롤러는 ADC를 통해 이 전압을 읽어들이고, 측정된 값을 바탕으로 어떤 버튼이 눌렸는지 판단할 수 있습니다.
이 방식을 사용하면 버튼 하나하나에 별도의 GPIO 핀을 할당할 필요 없이, 단일 아날로그 I/O 핀에 다수의 버튼을 연결할 수 있습니다. 실제로 이 기법을 활용하면 저항 값, ADC 분해능, 그리고 허용 가능한 오차 범위에 따라 단일 입력 핀에 10~20개, 혹은 그 이상의 버튼을 연결할 수 있습니다.
STEP 4. 전압 분배기-버튼 구성 방식의 작동 원리

각 버튼의 값을 읽어오기 위해, 예제 메뉴에 있는 ‘Analog Serial Read’ 스케치를 사용하여 각 버튼의 값을 읽어왔습니다.
void setup() {
Serial.begin(9600);
}
void loop() {
int sensorValue = analogRead(A0);
Serial.println(sensorValue);
delay(1); // delay in between reads for stability
}
A0 아날로그 핀은 버튼이 눌렸을 때 저항 사다리 회로에서 생성되는 다양한 전압 값을 읽어들이는 데 사용됩니다.
예를 들어, ‘위’ 버튼을 누르면 약 0의 아날로그 값이 표시되고, ‘아래’ 버튼을 누르면 약 511의 값이 표시됩니다. 각 버튼마다 고유한 값이 표시됩니다.
코드에서는 각 버튼에 대한 예상값과 약간의 허용 오차 범위를 정의합니다. 컨트롤러는 A0 핀에서 아날로그 값을 지속적으로 읽어와 미리 정의된 값들과 비교합니다. 읽은 값이 특정 버튼의 예상 범위 내에 속하면 해당 버튼이 눌린 것으로 간주하고, 그렇지 않으면 버튼이 놓인 것으로 처리합니다.
이 기능 덕분에 컨트롤러는 단일 아날로그 입력을 통해 여러 개의 버튼을 식별할 수 있습니다.
Pro Micro를 Arduino Nano ESP32로 교체한 후에도, 기존 컨트롤러의 이 부분은 전혀 변함이 없습니다. 저항 사다리와 A0 입력은 여전히 모든 버튼 입력을 처리하며, 단지 해당 입력을 처리하는 마이크로컨트롤러만 변경되었을 뿐입니다.
STEP 5. 하드웨어 - 아두이노 나노 ESP32


이 프로젝트의 핵심은 아두이노 나노 ESP32로, 게임 컨트롤러의 주 마이크로컨트롤러 역할을 합니다.
이 프로토타입에서는 기본적으로 이전 유선 컨트롤러에 사용했던 아두이노 프로 마이크로 클론을 아두이노 나노 ESP32로 교체했습니다. 프로 마이크로는 ATmega32U4를 기반으로 하는 반면, 나노 ESP32는 ESP32-S3를 기반으로 제작되어 컨트롤러의 처리 능력, 메모리 용량, 연결성이 크게 향상되었습니다.
Arduino Pro Micro는 16 MHz로 구동되는 8비트 ATmega32U4 AVR 마이크로컨트롤러를 사용합니다. 이 칩은 32KB의 플래시 메모리, 2.5KB의 SRAM, 1KB의 EEPROM을 갖추고 있습니다. 기존 컨트롤러에 비해 가장 큰 장점은 네이티브 USB 지원으로, 이를 통해 USB HID 장치로서 컴퓨터와 통신할 수 있습니다.
Nano ESP32는 Xtensa LX7 아키텍처를 기반으로 한 32비트 듀얼 코어 마이크로컨트롤러인 ESP32-S3를 사용하며, 클럭 속도는 최대 240 MHz입니다. ESP32-S3에는 512 KB의 SRAM, 384 KB의 ROM 및 추가 RTC 메모리가 포함되어 있습니다. 또한 Nano ESP32 보드는 프로그램 저장을 위한 외부 플래시 메모리를 제공합니다.
하지만 이 프로젝트의 가장 큰 장점은 연결성입니다. ESP32-S3에는 2.4GHz Wi-Fi 무선 통신 기능과 블루투스 저에너지(BLE)가 내장되어 있어 별도의 블루투스 모듈이 필요하지 않습니다.
STEP 6. 배선



이 보드의 배선은 꽤 간단했습니다. 저항 사다리 회로를 사용하여 A, B, X, Y 버튼과 D-패드 입력을 모두 하나의 아날로그 핀으로 처리할 수 있었으며, A0 핀이 이 모든 입력을 통합하여 처리했습니다.
나머지 제어 장치들은 다음과 같이 연결되었습니다.
- 오른쪽 조이스틱 Y: A1
- 오른쪽 조이스틱 X: A2
- 왼쪽 조이스틱 Y: A3
- 왼쪽 조이스틱 X: D4
- 오른쪽 조이스틱 스위치: D2
- 왼쪽 조이스틱 스위치: D3
- LT: D6
- RT: D7
처음에는 LT와 RT 입력 단자에 약간의 문제가 생겨서, 결국 각각 D6과 D7에 직접 연결했습니다. 연결에는 단심 은도금 구리선을 사용했습니다.
STEP 7. 코드
이 프로젝트에서 제가 사용한 주요 코드는 다음과 같으며, 아주 간단한 코드입니다.
#include <BleGamepad.h>
BleGamepad bleGamepad("Open-Game-Controller", "Arnov", 100);
// ---------------------------------------------------------------- amber LED
const int PIN_LED = 13;
const int LED_PWM_CHANNEL = 0;
const int LED_PWM_FREQ = 5000;
const int LED_PWM_RES = 8;
// ---------------------------------------------------------------- config
#define DEBUG_SERIAL 1
const int MATCH_TOLERANCE = 30;
const int IDLE_THRESHOLD = 1000;
const int SAMPLES = 8;
const int STICK_DEADZONE = 40;
const bool INVERT_LJ_X = false;
const bool INVERT_LJ_Y = true;
const bool INVERT_RJ_X = false;
const bool INVERT_RJ_Y = true;
const uint32_t DEBOUNCE_MS = 25;
// ---------------------------------------------------------------- pins
const int PIN_LADDER = A0;
const int PIN_RJ_Y = A1;
const int PIN_RJ_X = A2;
const int PIN_LJ_Y = A3;
const int PIN_LJ_X = D4;
const int PIN_RJ_SW = D2;
const int PIN_LJ_SW = D3;
const int PIN_BTN_LT = D6;
const int PIN_BTN_RT = D7;
// ---------------------------------------------------------------- mapping
#define XB_A BUTTON_1
#define XB_B BUTTON_2
#define XB_X BUTTON_3
#define XB_Y BUTTON_4
#define XB_LB BUTTON_5
#define XB_RB BUTTON_6
#define XB_LS BUTTON_9
#define XB_RS BUTTON_10
#define XB_UP BUTTON_11
#define XB_DOWN BUTTON_12
#define XB_LEFT BUTTON_13
#define XB_RIGHT BUTTON_14
struct LadderEntry {
int value;
int id;
const char *name;
};
LadderEntry ladder[] = {
{ 0, XB_UP, "UP" },
{ 510, XB_DOWN, "DOWN" },
{ 681, XB_LEFT, "LEFT" },
{ 767, XB_RIGHT, "RIGHT" },
{ 819, XB_Y, "Y" },
{ 852, XB_A, "A" },
{ 877, XB_X, "X" },
{ 900, XB_B, "B" }
};
const int LADDER_COUNT = sizeof(ladder) / sizeof(ladder[0]);
// ---------------------------------------------------------------- state
int ljXCentre = 512, ljYCentre = 512;
int rjXCentre = 512, rjYCentre = 512;
int lastLadderId = -1;
int stableLadderId = -1;
uint32_t ladderChangeMs = 0;
int prevSentId = -1;
bool prevLS = false, prevRS = false;
bool prevLT = false, prevRT = false;
bool prevConnected = false;
// ---------------------------------------------------------------- helpers
int readAveraged(int pin) {
long sum = 0;
for (int i = 0; i < SAMPLES; i++) sum += analogRead(pin);
return sum / SAMPLES;
}
int matchLadder(int value) {
if (value >= IDLE_THRESHOLD) return -1;
int bestId = -1;
int bestDist = MATCH_TOLERANCE + 1;
for (int i = 0; i < LADDER_COUNT; i++) {
int dist = abs(value - ladder[i].value);
if (dist < bestDist) {
bestDist = dist;
bestId = ladder[i].id;
}
}
return bestId;
}
int16_t axisToBle(int raw, int centre, bool invert) {
int delta = raw - centre;
if (abs(delta) < STICK_DEADZONE) return 16384;
delta += (delta > 0) ? -STICK_DEADZONE : STICK_DEADZONE;
int span = (delta > 0) ? (1023 - centre - STICK_DEADZONE)
: (centre - STICK_DEADZONE);
if (span < 1) span = 1;
long scaled = (long)delta * 16383 / span;
if (scaled > 16383) scaled = 16383;
if (scaled < -16383) scaled = -16383;
if (invert) scaled = -scaled;
return (int16_t)(16384 + scaled);
}
float axisMagnitude(int raw, int centre) {
int delta = abs(raw - centre);
if (delta < STICK_DEADZONE) return 0.0f;
delta -= STICK_DEADZONE;
int span = max(1023 - centre, centre) - STICK_DEADZONE;
if (span < 1) span = 1;
float m = (float)delta / (float)span;
if (m > 1.0f) m = 1.0f;
return m;
}
void ledSet(uint8_t brightness) {
ledcWrite(LED_PWM_CHANNEL, brightness);
}
// Common anode: LOW = on, HIGH = off.
void statusLedSet(bool red, bool green, bool blue) {
digitalWrite(LED_RED, red ? LOW : HIGH);
digitalWrite(LED_GREEN, green ? LOW : HIGH);
digitalWrite(LED_BLUE, blue ? LOW : HIGH);
}
// ---------------------------------------------------------------- setup
void setup() {
#if DEBUG_SERIAL
Serial.begin(115200);
#endif
analogReadResolution(10);
analogSetAttenuation(ADC_11db);
pinMode(PIN_LADDER, INPUT);
pinMode(PIN_LJ_SW, INPUT_PULLUP);
pinMode(PIN_RJ_SW, INPUT_PULLUP);
pinMode(PIN_BTN_LT, INPUT_PULLUP);
pinMode(PIN_BTN_RT, INPUT_PULLUP);
ledcSetup(LED_PWM_CHANNEL, LED_PWM_FREQ, LED_PWM_RES);
ledcAttachPin(PIN_LED, LED_PWM_CHANNEL);
ledSet(0);
pinMode(LED_RED, OUTPUT);
pinMode(LED_GREEN, OUTPUT);
pinMode(LED_BLUE, OUTPUT);
statusLedSet(true, false, false); // red at boot, not connected yet
delay(400);
ljXCentre = readAveraged(PIN_LJ_X);
ljYCentre = readAveraged(PIN_LJ_Y);
rjXCentre = readAveraged(PIN_RJ_X);
rjYCentre = readAveraged(PIN_RJ_Y);
BleGamepadConfiguration cfg;
cfg.setAutoReport(false);
cfg.setButtonCount(14);
cfg.setHatSwitchCount(0);
cfg.setWhichAxes(true, true, true, false, false, true, false, false);
cfg.setIncludeStart(true);
cfg.setIncludeSelect(true);
bleGamepad.begin(&cfg);
}
// ---------------------------------------------------------------- loop
void loop() {
bool connected = bleGamepad.isConnected();
if (connected != prevConnected) {
statusLedSet(!connected, connected, false);
prevConnected = connected;
}
if (!connected) {
ledSet(0);
delay(100);
return;
}
int raw = readAveraged(PIN_LADDER);
int id = matchLadder(raw);
if (id != lastLadderId) {
lastLadderId = id;
ladderChangeMs = millis();
}
if (millis() - ladderChangeMs >= DEBOUNCE_MS) {
stableLadderId = id;
}
if (stableLadderId != prevSentId) {
if (prevSentId >= 0) bleGamepad.release(prevSentId);
if (stableLadderId >= 0) bleGamepad.press(stableLadderId);
prevSentId = stableLadderId;
}
bool lt = (digitalRead(PIN_BTN_LT) == LOW);
bool rt = (digitalRead(PIN_BTN_RT) == LOW);
if (lt != prevLT) { lt ? bleGamepad.press(XB_LB) : bleGamepad.release(XB_LB); prevLT = lt; }
if (rt != prevRT) { rt ? bleGamepad.press(XB_RB) : bleGamepad.release(XB_RB); prevRT = rt; }
bool ls = (digitalRead(PIN_LJ_SW) == LOW);
bool rs = (digitalRead(PIN_RJ_SW) == LOW);
if (ls != prevLS) { ls ? bleGamepad.press(XB_LS) : bleGamepad.release(XB_LS); prevLS = ls; }
if (rs != prevRS) { rs ? bleGamepad.press(XB_RS) : bleGamepad.release(XB_RS); prevRS = rs; }
int ljXRaw = readAveraged(PIN_LJ_X);
int ljYRaw = readAveraged(PIN_LJ_Y);
int rjXRaw = readAveraged(PIN_RJ_X);
int rjYRaw = readAveraged(PIN_RJ_Y);
bleGamepad.setX (axisToBle(ljXRaw, ljXCentre, INVERT_LJ_X));
bleGamepad.setY (axisToBle(ljYRaw, ljYCentre, INVERT_LJ_Y));
bleGamepad.setZ (axisToBle(rjXRaw, rjXCentre, INVERT_RJ_X));
bleGamepad.setRZ(axisToBle(rjYRaw, rjYCentre, INVERT_RJ_Y));
bleGamepad.sendReport();
bool anyButtonHeld = (stableLadderId >= 0) || lt || rt || ls || rs;
if (anyButtonHeld) {
ledSet(255);
} else {
float ljMag = max(axisMagnitude(ljXRaw, ljXCentre), axisMagnitude(ljYRaw, ljYCentre));
float rjMag = max(axisMagnitude(rjXRaw, rjXCentre), axisMagnitude(rjYRaw, rjYCentre));
float mag = max(ljMag, rjMag);
ledSet((uint8_t)(mag * 255));
}
#if DEBUG_SERIAL
static uint32_t lastPrint = 0;
if (millis() - lastPrint > 250) {
lastPrint = millis();
Serial.print("ladder "); Serial.print(raw);
Serial.print(" LT "); Serial.print(lt);
Serial.print(" RT "); Serial.print(rt);
Serial.print(" LJ "); Serial.print(ljXRaw); Serial.print("/"); Serial.print(ljYRaw);
Serial.print(" RJ "); Serial.print(rjXRaw); Serial.print("/"); Serial.println(rjYRaw);
}
#endif
delay(10);
}
이 과정은 꽤 간단하며, 블루투스 HID 기능의 대부분은 BleGamepad 라이브러리가 처리합니다.
블루투스 게임패드 라이브러리
#include <BleGamepad.h>
BleGamepad bleGamepad("Open-Game-Controller", "Arnov", 100);
BleGamepad 라이브러리는 프로젝트의 블루투스 HID 부분을 처리하여, Nano ESP32가 무선 게임 컨트롤러로 인식되도록 합니다.
첫 번째 줄에서는 라이브러리를 포함하고, 두 번째 줄에서는 게임패드 인스턴스를 생성합니다. 여기서는 컨트롤러 이름을 ‘Open-Game-Controller’로 지정하고, 제조사 이름을 ‘Arnov’로 설정했으며, 표시되는 배터리 잔량을 100%로 설정했습니다.
컨트롤러 구성
#define DEBUG_SERIAL 1
const int MATCH_TOLERANCE = 30;
const int IDLE_THRESHOLD = 1000;
const int SAMPLES = 8;
const int STICK_DEADZONE = 40;
const bool INVERT_LJ_X = false;
const bool INVERT_LJ_Y = true;
const bool INVERT_RJ_X = false;
const bool INVERT_RJ_Y = true;
const uint32_t DEBOUNCE_MS = 25;
이 변수들은 컨트롤러의 기본적인 동작 중 일부를 정의합니다.
SAMPLES는 아날로그 입력을 읽을 때 ADC 측정값을 몇 개나 채취할지 결정합니다. 저는 8개의 샘플을 사용하고 이를 평균화하여 노이즈를 줄입니다.
STICK_DEADZONE은 조이스틱의 중심 위치 주변에서 무시될 움직임의 범위를 정의합니다. 이를 통해 조이스틱의 미세한 흔들림이 움직임으로 인식되는 것을 방지합니다.
INVERT 변수를 사용하면 조이스틱의 물리적 방향 때문에 움직임이 반대로 해석되는 경우, 해당 축의 방향을 반전시킬 수 있습니다.
MATCH_TOLERANCE는 저항 사다리를 통해 연결된 버튼을 감지하는 데 사용되며, DEBOUNCE_MS는 짧은 변동 현상이 여러 번의 버튼 누름으로 오인되는 것을 방지합니다.
핀 매핑
const int PIN_LADDER = A0;
const int PIN_RJ_Y = A1;
const int PIN_RJ_X = A2;
const int PIN_LJ_Y = A3;
const int PIN_LJ_X = D4;
const int PIN_RJ_SW = D2;
const int PIN_LJ_SW = D3;
const int PIN_BTN_LT = D6;
const int PIN_BTN_RT = D7;
이 섹션에서는 각 물리적 제어 장치가 Nano ESP32의 어느 부분에 연결되는지 설명합니다.
A, B, X, Y 버튼과 D패드는 A0 에 저항 사다리를 통해 연결되어 있습니다. 두 조이스틱은 X축과 Y축에 각각 사용할 4개의 아날로그 입력을 사용합니다.
조이스틱 클릭 스위치인 LT와 RT는 디지털 GPIO 핀에 연결되어 있습니다.
물리적 버튼 매핑하기
#define XB_A BUTTON_1
#define XB_B BUTTON_2
#define XB_X BUTTON_3
#define XB_Y BUTTON_4
#define XB_LB BUTTON_5
#define XB_RB BUTTON_6
#define XB_LS BUTTON_9
#define XB_RS BUTTON_10
#define XB_UP BUTTON_11
#define XB_DOWN BUTTON_12
#define XB_LEFT BUTTON_13
#define XB_RIGHT BUTTON_14
여기서는 물리적 컨트롤을 BleGamepad 라이브러리가 기대하는 버튼 ID에 매핑합니다.
예를 들어, 물리적 A 버튼을 누르면 결국 다음과 같은 결과가 나타납니다:
이는 HID 게임패드 리포트의 BUTTON_1에 해당합니다.
컨트롤러에 Xbox 스타일의 버튼 배치를 사용하고 있어, 표준 게임패드를 지원하는 게임과 호환됩니다.
저항 사다리
struct LadderEntry {
int value;
int id;
const char *name;
};
LadderEntry ladder[] = {
{ 0, XB_UP, "UP" },
{ 510, XB_DOWN, "DOWN" },
{ 681, XB_LEFT, "LEFT" },
{ 767, XB_RIGHT, "RIGHT" },
{ 819, XB_Y, "Y" },
{ 852, XB_A, "A" },
{ 877, XB_X, "X" },
{ 900, XB_B, "B" }
};
바로 이때 저항 사다리가 유용하게 쓰입니다.
모든 버튼에 별도의 GPIO 핀을 할당하는 대신, 각 버튼은 서로 다른 전압을 발생시키며, 이 전압은 단일 아날로그 핀 A0을 통해 측정할 수 있습니다.
예를 들어, 약 852 정도의 ADC 값은 A 버튼을 나타내고, 약 900 정도의 값은 B 버튼을 나타냅니다.
컨트롤러는 측정된 ADC 값을 이 표와 비교하여 가장 근접한 버튼을 선택합니다.
이를 통해 8개의 서로 다른 입력 신호가 하나의 아날로그 핀을 공유할 수 있어, GPIO 수를 상당히 절약할 수 있습니다.
아날로그 측정값의 평균 계산
int readAveraged(int pin) {
long sum = 0;
for (int i = 0; i < SAMPLES; i++)
sum += analogRead(pin);
return sum / SAMPLES;
}
아날로그 측정값은 완벽하게 안정적이지 않으므로, ADC 측정값을 한 번만 읽는 대신 여덟 번 측정하여 그 평균을 구합니다.
예를 들어, ADC가 다음과 같은 값을 반환한다면:
펌웨어는 이 값들을 사용하기 전에 평균을 구합니다.
이를 통해 조이스틱과 저항 사다리 회로 모두에서 보다 안정적인 측정값을 얻을 수 있습니다.
버튼 래더 감지하기
int matchLadder(int value) {
if (value >= IDLE_THRESHOLD)
return -1;
int bestId = -1;
int bestDist = MATCH_TOLERANCE + 1;
for (int i = 0; i < LADDER_COUNT; i++) {
int dist = abs(value - ladder[i].value);
if (dist < bestDist) {
bestDist = dist;
bestId = ladder[i].id;
}
}
return bestId;
}
이 함수는 A0에서 ADC 값을 읽어와 어떤 버튼이 눌려 있는지 파악합니다.
측정값이 유휴 임계값을 초과하면, 컨트롤러는 어떤 버튼도 눌리지 않은 것으로 간주합니다.
그렇지 않으면, 저항 사다리 표에 있는 모든 값을 확인하며 측정값과 예상값 간의 차이를 계산합니다.
가장 일치하는 항목이 감지된 버튼으로 지정됩니다.
이 허용 오차 값은 전혀 관련이 없는 ADC 측정값이 유효한 버튼 누름으로 인식되는 것을 방지합니다.
조이스틱 입력 변환
int16_t axisToBle(int raw, int centre, bool invert) {
int delta = raw - centre;
if (abs(delta) < STICK_DEADZONE)
return 16384;
delta += (delta > 0)
? -STICK_DEADZONE
: STICK_DEADZONE;
int span = (delta > 0)
? (1023 - centre - STICK_DEADZONE)
: (centre - STICK_DEADZONE);
if (span < 1)
span = 1;
long scaled = (long)delta * 16383 / span;
if (scaled > 16383) scaled = 16383;
if (scaled < -16383) scaled = -16383;
if (invert)
scaled = -scaled;
return (int16_t)(16384 + scaled);
}
이 함수는 조이스틱에서 얻은 원시 ADC 값을 블루투스 HID 게임패드가 기대하는 범위로 변환합니다.
Nano ESP32는 0에서 1023까지의 10비트 ADC 값을 제공하는 반면, HID 축은 더 넓은 부호 있는 범위로 표현됩니다.
이 함수는 먼저 보정된 중심 위치를 빼고, 데드존을 적용한 다음, 남은 이동량을 조정합니다.
그 결과는 대략 다음과 같습니다:
Joystick left → 0
Joystick centre → 16384
Joystick right → 32767
'반전' 매개변수를 사용하면 필요할 때 축의 방향을 반대로 바꿀 수 있습니다.
컨트롤러 초기화
void setup() {
Serial.begin(115200);
analogReadResolution(10);
analogSetAttenuation(ADC_11db);
pinMode(PIN_LADDER, INPUT);
pinMode(PIN_LJ_SW, INPUT_PULLUP);
pinMode(PIN_RJ_SW, INPUT_PULLUP);
pinMode(PIN_BTN_LT, INPUT_PULLUP);
pinMode(PIN_BTN_RT, INPUT_PULLUP);
setup() 함수는 컨트롤러의 전원이 켜질 때 한 번 실행됩니다.
여기서는 디버깅을 위해 시리얼 연결을 초기화하고, ADC를 구성하며, ESP32의 내장 풀업 저항을 사용하여 디지털 버튼 핀을 입력으로 설정합니다.
INPUT_PULLUP을 사용하면 버튼을 놓았을 때 HIGH로, 눌렀을 때 LOW로 감지됩니다.
조이스틱 보정
ljXCentre = readAveraged(PIN_LJ_X);
ljYCentre = readAveraged(PIN_LJ_Y);
rjXCentre = readAveraged(PIN_RJ_X);
rjYCentre = readAveraged(PIN_RJ_Y);
시작 시, 컨트롤러는 4개의 조이스틱 축 모두의 초기 위치를 측정합니다.
조이스틱의 중심이 정확히 512라고 가정하는 대신, 각 조이스틱의 실제 위치를 측정하여 저장합니다.
실제 조이스틱 모듈들은 서로 완전히 동일하지 않고 중심값이 약간씩 다를 수 있기 때문에 이 기능이 유용합니다.
유일한 주의점은 컨트롤러가 부팅되는 동안 조이스틱을 건드리지 않아야 한다는 것입니다.
블루투스 게임패드 설정하기
BleGamepadConfiguration cfg;
cfg.setAutoReport(false);
cfg.setButtonCount(14);
cfg.setHatSwitchCount(0);
cfg.setWhichAxes(
true, true, true, false,
false, true, false, false
);
cfg.setIncludeStart(true);
cfg.setIncludeSelect(true);
bleGamepad.begin(&cfg);
여기서는 블루투스 HID 장치 자체를 설정합니다.
컨트롤러에는 14개의 버튼과 필요한 아날로그 축이 제공됩니다. 또한 setAutoReport(false)를 사용하여 자동 보고 기능을 비활성화합니다.
즉, 개별 입력값이 바뀔 때마다 보고를 보내는 대신, 펌웨어가 현재 컨트롤러 상태를 언제 전송할지 결정한다는 뜻입니다.
모든 설정이 완료되면, bleGamepad.begin()을 호출하여 블루투스 게임패드를 시작합니다.
메인 루프
void loop() {
bool connected = bleGamepad.isConnected();
if (!connected) {
ledSet(0);
delay(100);
return;
}
// Read controller inputs...
bleGamepad.sendReport();
delay(10);
}
loop() 함수는 컨트롤러가 입력 신호를 지속적으로 읽어들이는 곳입니다.
먼저, 블루투스 기기가 연결되어 있는지 확인합니다. 연결된 기기가 없으면 그냥 대기합니다.
연결이 완료되면 펌웨어는 버튼 및 조이스틱 위치를 읽어들이고, 입력을 처리하며, 게임패드 상태를 업데이트한 후, 다음을 사용하여 전체 보고서를 전송합니다:
그런 다음 이 루프는 대략 10밀리초마다 반복됩니다.
조이스틱 판독하기
int ljXRaw = readAveraged(PIN_LJ_X);
int ljYRaw = readAveraged(PIN_LJ_Y);
int rjXRaw = readAveraged(PIN_RJ_X);
int rjYRaw = readAveraged(PIN_RJ_Y);
bleGamepad.setX(
axisToBle(ljXRaw, ljXCentre, INVERT_LJ_X)
);
bleGamepad.setY(
axisToBle(ljYRaw, ljYCentre, INVERT_LJ_Y)
);
bleGamepad.setZ(
axisToBle(rjXRaw, rjXCentre, INVERT_RJ_X)
);
bleGamepad.setRZ(
axisToBle(rjYRaw, rjYCentre, INVERT_RJ_Y)
);
조이스틱의 4개 축 값을 읽어와 평균을 구한 뒤, HID 값으로 변환하여 해당 게임패드 축에 할당합니다.
왼쪽 조이스틱은 X와 Y를 제어하고, 오른쪽 조이스틱은 Z와 RZ를 담당합니다.
이 단계에서 조이스틱의 물리적 움직임이 블루투스 게임패드가 인식할 수 있는 디지털 값으로 변환되었습니다.
최종 보고서 제출
마지막으로, 현재의 모든 버튼 및 조이스틱 상태가 블루투스 HID 리포트 형태로 연결된 기기로 전송됩니다.
따라서 컴퓨터는 개별 버튼, 저항 사다리 회로, 조이스틱 ADC 값, 또는 아두이노 자체에 대해 아무것도 알 필요가 없습니다. 운영 체제 입장에서는 단순히 표준 블루투스 게임 컨트롤러와 통신하고 있을 뿐입니다.
전체 과정을 다음과 같이 요약할 수 있습니다.
물리적 제어 → ADC/GPIO → 입력 처리 → HID 값 → BleGamepad → 블루투스 → 컴퓨터
그리고 이것이 바로 이 프로토타입이 여러 개의 버튼과 아날로그 조이스틱을 모아 기능적인 무선 게임 컨트롤러로 만들어내는 기본 원리입니다.
본문이 길어 아래 덧글 1개로 계속됩니다.
원문: https://www.instructables.com/Wireless-Game-Controller-V1/