Building a Custom Drone Controller from Scratch — Part 5: BLE HID Gamepad Host

In the original Macross series, the SDF-1 doesn’t set out to fight anyone. It launches because an alien fleet is already on its way and there is no other choice. The ship is barely understood, its fold drive untested, its crew a mix of civilians and soldiers who never signed up for an interstellar war. They leave Earth with what they have, figure out the rest along the way, and somehow keep flying.

That’s roughly the spirit in which this project approaches Bluetooth gamepad support.

After Part 4, the drone flew. Tilt control — mapping the AtomS3’s accelerometer to roll and pitch — worked well enough to navigate a room. But holding a small board at a precise angle for minutes at a time is genuinely tiring. Fatigue tremors start showing up in the roll and pitch axes. After a few sessions the wrists were unhappy and the drone was responding to them more than to intent.

The obvious solution: a gamepad. The ESP32-S3 has a Bluetooth radio. Gamepads speak Bluetooth. The SDF-1 had a fold drive. Let’s fire it up and see where we end up.

The Fold Drive — BLE HID Host

The ESP32-S3 supports BLE — Bluetooth Low Energy. The Arduino ESP32 platform ships a BLE library (BLEDevice.h) that can act as a HID host: scan for devices advertising the HID service (UUID 0x1812), connect, subscribe to Input Report notifications, and read raw bytes. No extra lib_deps entry required — the library is already aboard.

The approach is deliberately simple. No gamepad abstraction layer, no universal report parser. Scan for HID, connect, log the raw bytes, figure out the format, map it to flight axes. Every controller does its own thing with HID reports, so the format has to be discovered per-device anyway.

Following the project’s discipline on dependencies, all of this lives in src/bt/ble_gamepad.cpp. Nothing outside that file ever sees a BLEDevice or a BLEClient. The rest of the firmware receives a GamepadAxes struct — normalised values in [−1, 1], fully decoded — and never knows whether they came from Bluetooth, a touchscreen, or a Zentradi battle pod.

The fold drive is spinning. Now we need somewhere to go.

Unexpected Destination — the iPega PG-9021S

In Macross, the first fold jump doesn’t go where anyone planned. The SDF-1 executes the fold and ends up near Pluto — not Earth orbit, not anywhere useful — with the entire civilian city of Macross dragged along for the ride. It is not the mission. But the ship is intact, the crew is alive, and they have no choice but to work with where they are.

The first controller in hand was an iPega PG-9021S — a telescoping gamepad designed for phones, with two analog sticks, a D-pad, four face buttons, and shoulder triggers. It supports multiple pairing modes triggered by different button combos at power-on.

The first attempt: HOME + X — Android Standard Gamepad mode. The controller connected, appeared in the BLE scan, handshaked — and then disconnected. Every time. The root cause, after some digging, was BLE bonding. Standard Gamepad mode requires a security handshake that the ESP32 Arduino BLE library doesn’t perform. Without it, the controller politely refuses to stay paired.

The second attempt: HOME + A. Connected. Stayed connected. Data flowing.

This is an undocumented mode added in a 2019 iPega firmware update, officially called “Direct Play.” Designed for mobile games that read touch input directly, bypassing Android’s controller API entirely. The name should have been a warning about the destination.

In HOME+A mode, the iPega does not present itself as a gamepad. It presents itself as a touchscreen.

Not Pluto. But intact. And flying.

Reverse-Engineering Zentradi Technology — the 17-Byte Report

The Zentradi’s technology was incomprehensible to humans at first contact. It took time, captured ships, and a lot of dangerous guesswork to figure out what any of it actually did. The understanding, when it came, was hard-won.

When the iPega connects in HOME+A mode, it advertises HID Usage Page 0x0D (Digitizer), Usage 0x04 (Touch Screen). Every input report is 17 bytes — four 4-byte contact blocks, followed by a 1-byte contact count. Exactly how a multi-touch phone screen reports fingers.

Each 4-byte contact block is little-endian bit-packed:

bit 0:      Tip Switch (1 = finger touching)
bit 1:      In Range
bits 2–3:   padding
bits 4–7:   Contact ID (always 0 — both sticks share it)
bits 8–19:  X coordinate  (0–1200)
bits 20–31: Y coordinate  (0–2200, top = 0)

Extracting X and Y from bytes b[1]b[2]b[3]:

uint16_t X = b[1] | ((b[2] & 0x0F) << 8);
uint16_t Y = (b[2] >> 4) | (b[3] << 4);

The controller’s screen is portrait internally, held in landscape. Physical UP/DOWN maps to X (up = X increases); LEFT/RIGHT maps to Y (left = Y increases).

Both sticks report Contact ID 0 — there is no field to distinguish left from right. The solution: Y coordinate. Values below 1000 are the left stick area; 1000 and above are the right stick area. The virtual touchscreen was laid out with enough separation that this split is reliable.

Technology understood. Now it can be used.

Calibration — Learning to Aim

Hikaru Ichijo wasn’t a natural in a Valkyrie on day one. He logged hours in the simulator, then more hours in real combat, learning how the machine responded before he could trust it.

Calibration here was simpler: move each stick to its extremes and resting center while logging raw reports. The numbers that came out:

AxisStickCoordinateCenterRange
ThrottleLeft UP/DOWNX523±300
YawLeft LEFT/RIGHTY500±240
PitchRight UP/DOWNX533±145
RollRight LEFT/RIGHTY1650±120

Each axis normalises to [−1, 1]: (coordinate − center) / range. The result then passes through the same dead zone, expo curve, and slew-rate limiter used by the tilt controller — so switching between control modes doesn’t change the feel of the drone.

Both drones run altitude-hold firmware. Center stick means maintain altitude; deflect to climb or descend; release and the drone hovers wherever it is. The critical detail: the moment the stick returns inside the dead band, throttle snaps immediately back to neutral (0x80). Leave a residual value and the drone keeps climbing forever. Get that snap-back right and altitude control feels natural.

The FAST Packs — Mapping the Buttons

The FAST Packs in Macross weren’t part of the original Valkyrie design. They were add-ons — extra weapon and propulsion modules bolted onto a frame that was never built to carry them. They worked because the engineers understood the underlying system well enough to extend it.

Buttons in HOME+A mode appear as additional touch contacts at fixed (x, y) positions. The iPega is emulating PUBG Mobile’s on-screen button layout — each physical button maps to a virtual finger tap on a specific point of the touchscreen. There is no HID button bitmask. Discovery was purely empirical: press one button at a time, log the 17-byte report, note the coordinates.

ButtonxyDrone command
A3322044Arm + takeoff
B7801281Land
X2411270Emergency stop
Y8182020360° flip
D-pad UP723544Headless mode toggle
D-pad DOWN352562Calibrate gyro
D-pad LEFT534379Toggle screen on/off
LT1173416Lock motors
R15782041Unlock motors

Detection uses ±30 pixel tolerance on each coordinate. Contacts are checked against this table before the stick zone classifier, so a button press near a zone boundary doesn’t accidentally register as axis movement.

Nine buttons. Working. The FAST Packs are on.

A Battle Won, A War Lost — the L1/RT/L3/R3 Investigation

Not every engagement in Macross ends cleanly. There are missions where the Valkyrie comes back intact but the objective wasn’t fully achieved — where you did everything right and it still wasn’t enough, for reasons outside your control.

Four buttons — L1, RT, L3, R3 — never appeared in any log. No contact, no matter how hard or how long they were pressed.

The controller was opened. The switches under all four had cracked solder joints — a common failure on telescoping gamepads that get repeatedly flexed. All four were resoldered. The controller went back together.

Plugged into an Android phone running a gamepad test app, in Standard Gamepad mode (HOME+X): all four buttons registered immediately. Clean signals. The hardware was fixed.

Back on the AtomS3, in HOME+A mode, with both analog sticks held deflected to keep two contacts active: press L1. The contact count never reached three. No third contact appeared for L1, RT, L3, or R3, in any combination.

The explanation is in what Direct Play mode actually emulates. PUBG Mobile’s touchscreen UI was designed for a phone. It never had L1, RT, L3, or R3 — those concepts don’t exist in a mobile touch game. The iPega’s firmware never assigned those buttons a position on the virtual screen. The hardware repair was real and complete. The limitation is a protocol design decision made years ago for a different platform entirely.

“It registers in Android’s controller test” and “it generates a contact in this HID profile” are two different questions. This was a useful reminder that confirming something works in one context says nothing about whether it works in another. The battle with the solder joints was won. The war for those four buttons was already lost before the controller was even opened.

We fly with nine.

The Zentradi Main Fleet — 8BitDo

In Macross, the first Zentradi ships are manageable. A squadron here, a patrol fleet there. The SDF-1 fights them off, learns their patterns, adapts. Then the main fleet arrives. Seven million warships. Not a battle. A wall.

With the iPega working, a natural question arose: could a proper console-style gamepad work? On hand: an 8BitDo SF30 Pro, an 8BitDo Zero 2, an 8BitDo Ultimate 2C, and an Xbox One S. All marketed as Bluetooth controllers. None of them connected. Every BLE scan came back empty.

LightBlue, a Bluetooth scanner app on a phone, also saw nothing from these controllers. But Android’s native Bluetooth pairing screen found the 8BitDo Zero 2 and Ultimate 2C instantly — listed as “Pro Controller.”

That name was the answer. “Pro Controller” is Nintendo’s Switch Pro Controller. 8BitDo’s Switch-emulation mode — activated by X+Start or the model-specific equivalent — makes the controller advertise itself as one. Android finds it because Android’s Bluetooth stack supports Classic Bluetooth, known as BR/EDR. The Pro Controller protocol runs over BR/EDR.

The ESP32-S3 does not support BR/EDR. There is no Classic Bluetooth radio on the die. This isn’t a software configuration — it’s the chip itself. Espressif’s own datasheet confirms it. The Bluepad32 project’s FAQ states it plainly:

“Only BLE gamepads are supported on ESP32-S3, since BR/EDR is not supported… Controllers like Switch, Wii, DualSense, DualShock, etc. only talk BR/EDR.”

Every failed attempt was the correct outcome. Four controllers, many pairing modes, many attempts. All correct. No sequence, no library, no firmware trick changes this. It is the hardware. The only ESP32 variant with a dual-mode radio — BLE and BR/EDR together — is the original ESP32, not the S3, C3, C6, or H2 variants. The AtomS3 runs an S3.

Seven million warships. This one doesn’t get won today.

Do You Remember Love — What We Have

At the end of Macross, Earth is not what it was. The battle left marks that don’t erase. But Hikaru is alive. Misa is alive. The SDF-1 is battered but standing. And somewhere in the ruins, Minmay is still singing — because culture and music turned out to matter more than anyone expected, and the proof of that is that anyone is around to remember it.

The BLE gamepad mode works. The iPega PG-9021S in HOME+A mode — a touchscreen disguised as a gamepad, discovered by accident when the obvious path was blocked — gives full analog control of all four flight axes plus nine functional buttons. The flight feel matches the tilt controller for roll and pitch, with the ergonomic advantage of a real grip in your hands instead of a board held sideways.

Nine of thirteen buttons. Four will never come. SELECT and START are reserved by the controller’s firmware for mode-switching and never appear in any HID report. The iPega works with what it is, which is more than enough to fly.

8BitDo controllers cannot work on this hardware. The AtomS3’s ESP32-S3 simply does not have the radio they speak. That question has been asked thoroughly, answered conclusively, and put to rest.

The SDF-1 didn’t come home with everything it left with. But it came home. The drone flies. And somewhere out there, the universe is still very large.

Next: a second drone arrives, with a completely different protocol hidden inside — and it took a packet capture to find out what it actually spoke.

Enjoy — PopolonY2k

Building a Custom Drone Controller from Scratch — Part 4: Accelerometer Tilt Control

If you’ve seen Pacific Rim, you know the core idea behind a Jaeger: a pilot doesn’t push buttons to make the giant robot punch — they punch, and the robot punches with them. The machine reads the pilot’s body and mirrors it. That’s the exact idea behind the first control mode I built for the Maritaca Force 1 and Dr.One drones: you tilt the controller, the drone tilts with you.

No joystick, no buttons for movement — just an M5Stack AtomS3 in your hand, leaning the way you want the drone to fly. In this article I’ll walk through how that actually works under the hood: turning raw motion-sensor numbers into smooth flight commands, the gesture I use for throttle (which is not tilt-based, for a good reason), and a yaw-control redesign that turned out to accidentally fix a completely unrelated bug.

The big idea: tilt is data, not magic

Inside the AtomS3 sits a small chip that measures two different things: acceleration (how the board is oriented relative to gravity) and rotation rate (how fast it’s spinning). Tilt the board to the left, and gravity “pulls” more on one axis than another — from that pull, we can calculate an angle. That angle is the only ingredient we need for roll and pitch.

float pitchDeg = atan2f(imu.ax, sqrtf(imu.ay * imu.ay + imu.az * imu.az)) * (180.0f / M_PI);
float rollDeg  = atan2f(imu.ay, sqrtf(imu.ax * imu.ax + imu.az * imu.az)) * (180.0f / M_PI);

Don’t worry about the trigonometry — the short version is: atan2f converts the raw gravity readings on each axis into an angle in degrees. Tilt the board 15° to the right, and rollDeg comes out as roughly 15. That’s it. From here on, it’s all about turning that number into something a drone can actually use safely.

From a raw angle to a flight command

If we sent that raw angle straight to the drone, two things would go wrong immediately. First, your hand is never perfectly still — even resting flat, there’s a tiny natural tremor that the sensor happily reports as “tilt.” Second, a twitchy, instant response to every micro-movement would make the drone feel nervous and hard to fly precisely.

Two small ideas fix both problems:

  • Dead zone — ignore any tilt smaller than about 10°. Below that threshold, we treat the board as “flat,” even if the sensor reports a tiny non-zero number. This is what keeps the drone from drifting on its own just because your hand isn’t a tripod.
  • Expo curve — once you’re past the dead zone, small additional tilts produce small movements, and big tilts produce big movements, but the relationship isn’t a straight line — it curves, so fine control near the center is easier and the extremes are still reachable.
uint8_t AccelController::mapAxis(float value, float maxRange, float deadZone, float expo) {
    if (fabsf(value) &lt; deadZone) return 0x80; // 0x80 = neutral, dead-center

    float sign   = value &gt; 0.0f ? 1.0f : -1.0f;
    float scaled = (fabsf(value) - deadZone) / (maxRange - deadZone);
    if (scaled &gt; 1.0f) scaled = 1.0f;

    scaled = scaled * ((1.0f - expo) + expo * scaled); // the "curve"
    return (uint8_t)((0.5f + sign * scaled * 0.5f) * 254.0f);
}

One more ingredient, and this is the one that actually makes it feel like piloting rather than flicking a switch: a slew rate limiter. Even if you snap the board from flat to a hard tilt instantly, the output value isn’t allowed to jump instantly — it ramps there over a fraction of a second. It’s the same principle as a car’s steering having weight to it instead of feeling like an on/off switch. Tiny detail, huge difference in how trustworthy the drone feels in your hand.

Throttle: the one gesture that isn’t tilt

Here’s a detail that surprised me during testing: both drones run what’s called altitude-hold firmware. Left alone, they actively fight to stay at whatever height they’re already at — like a Jaeger’s auto-balance systems quietly correcting your footing so you don’t topple over. That’s great for a beginner-friendly drone, but it means throttle can’t just be “tilt forward = climb” the way roll and pitch are tilt-based. Instead, throttle uses the only physical button on the AtomS3:

  • Press and hold the screen button → climb
  • Click once, then press and hold → descend
  • Let go → the drone snaps back to hovering at whatever height it just reached

That last part — snapping back to neutral the instant you release — took a real bug to discover. My first version kept whatever throttle value you’d built up even after you let go, which sounds harmless until you realize it means the drone keeps climbing (or sinking) forever after you’ve stopped touching anything. On altitude-hold firmware, “neutral” isn’t zero — it’s “stay right here,” so the fix was just snapping back to that neutral value the moment your thumb leaves the button.

The yaw redesign: when “more realistic” makes things worse

This is the part of the story I actually want to tell, because it’s a good example of a control scheme that looked elegant on paper and was miserable in practice.

My first idea for yaw (spinning the drone left/right in place) was to use the gyroscope’s twist-rate — physically rotate the flat board like a steering wheel lying on a table, and the drone spins to match. It’s the most “drift-compatible-pilot” idea of the whole project: your hand’s rotation becomes the robot’s rotation, no abstraction in between.

It was also nearly unflyable. Twisting a small board flat on your palm, with enough precision to stop exactly where you want, turns out to be a genuinely hard physical motion — much harder than tilting it, which your wrist already does naturally. Worse, any tiny ambient drift in the gyroscope reading (sensors are never perfectly zeroed) had a path all the way to the drone’s yaw command, which I suspect caused an unrelated mystery: the drone would slowly spin on the ground before even taking off, for no command I could find in the logs.

The fix ended up being almost embarrassingly simple. Instead of inventing a new gesture, I reused the existing one: a single click of the screen button toggles a “yaw mode,” and while it’s on, the same left/right tilt that normally controls roll gets rerouted to control yaw instead:

out.roll  = yawModeActive ? DroneAxis::NEUTRAL    : (uint8_t)_currentRoll;
out.yaw   = yawModeActive ? (uint8_t)_currentRoll : DroneAxis::NEUTRAL;

Click once, tilt left/right to spin in place; click again, and the same tilt goes back to strafing. No new motion to learn, and the gyroscope is no longer involved in flight at all — which meant that mystery ground-spin bug disappeared the moment this shipped, without me touching it directly. Sometimes the best fix for a bug is removing the entire mechanism it was hiding in.

What’s next

Tilt control gets you flying with nothing but your hand and a tiny screen, but it has a ceiling — there’s only so much precision your wrist can offer, and only one physical button to work with. In the next article, I’ll cover the second control mode I built: a full Bluetooth gamepad host running directly on the ESP32, including a very unexpected discovery about how a $15 controller pretends to be a touchscreen.

Enjoy

[]’s
PopolonY2k