
Sun Nov 23 2025
Flying an off-the-shelf drone with an Xbox controller
Connecting a BetaFPV AIR75 to my laptop via a Radiomaster Nano transmitter with an ELRS module, so I could fly it with an Xbox controller. The control chain came together in about a week. The video chain didn't.
- DRONES
- ELRS
- BETAFLIGHT
The pitch
I bought a small drone — a BetaFPV AIR75 (75 mm racing quad, STM32G473 flight controller, 2S brushless) — wanting to fly it from an Xbox controller plugged into my laptop. No FPV goggles, no dedicated stick radio, just: gamepad in my hands, drone in the air, with my laptop sitting in the middle as the translator.
The chain I needed to make work:
Xbox controller
→ laptop reads the gamepad axes
→ laptop encodes them as CRSF packets
→ CRSF packets go out a serial port
→ Radiomaster Nano TX module reads them
→ ELRS module RF-broadcasts at 2.4 GHz
→ drone's onboard ELRS receiver picks it up
→ Betaflight on the flight controller maps channels to motors
→ motors spin
Eight links in a line. Each is a different protocol, often a different vendor, sometimes a different physical layer. If anything in the chain doesn't speak the same dialect as the next thing, nothing moves.
It took about a week. The control chain came together. The video chain — getting the FPV camera feed back to my laptop — didn't.
Hardware bill
- BetaFPV AIR75 — 75 mm racing drone. Flight controller is a BetaFPV G473 (target
BETAFPVG473, STM32G47X), 12-pole brushless motors on DShot300 ESCs, runs on 2S LiPo. - Radiomaster Nano TX module — a small handheld "module-format" radio with an ExpressLRS (ELRS) RF stage. It takes CRSF packets in over its serial input and broadcasts them.
- Xbox controller — generic Series X pad.
- Laptop — sits in the middle and translates.
- elrs-joystick-control — open-source Go application that does the actual translation.
The interesting protocol is CRSF (Crossfire). It's a serial protocol that packs 16 RC channels at 11 bits each, runs at 921600 baud, and is the de facto lingua franca between RC transmitters and modern flight controllers. The Radiomaster Nano speaks it. Betaflight speaks it. Anything in the middle needs to.
The control loop
elrs-joystick-control is the Go app I cloned and ran on my laptop. It does five things:
- Reads the Xbox controller via SDL2 (raw axis values −32768..+32767).
- Maps each axis to a CRSF channel via a web UI on
localhost:3000. - Packs the channels into CRSF frames (with the 11-bit CRC and all).
- Writes them out a configured serial port at 921600 baud, on a ~50 Hz cadence.
- Reads telemetry the other direction (RSSI, link quality, battery voltage).
Setup is just go run cmd/elrs-joystick-control/main.go --tx-serial-port-name /dev/cu.usbserial-XXX plus a few minutes in the web UI to bind sticks to channels.
That part worked the first time, which is what made the rest of the week so confusing.
What actually took the time
What took the time wasn't the serial cable, or the Go app, or the Xbox controller. It was getting the Nano TX and the drone's onboard receiver — both running ExpressLRS — to actually bind to each other.
This sounds like it should be a non-problem. They're the same protocol, made for each other. Both even host their own WiFi access points, with little web pages where you can flash firmware, set the binding phrase, pick the regulatory power profile. I'd open the Nano's AP, set a binding phrase. Open the receiver's AP, set the same binding phrase. Power-cycle both. The receiver's LED would blink "no link" forever.
I spent days on this. Some of the failure modes I worked through:
- The TX and the RX were on slightly different ExpressLRS firmware versions. ELRS is strict about this — even minor-version mismatches won't bind.
- The TX was on a power profile the RX didn't expect.
- The binding phrase had a stray character (the WiFi page didn't trim whitespace).
- The receiver's firmware version on the BetaFPV-shipped image was old enough that I needed to reflash it before any phrase-based bind would work.
The unlock was an open-source desktop app — the ExpressLRS Configurator (I'm fairly sure that's the one I used; possibly with a related flasher in the loop). Instead of flashing each side through its own WiFi AP independently and hoping the configs matched, the Configurator lets you pick a target firmware version, a binding phrase, and a regulatory profile once, and then it builds and flashes that exact config to both sides over USB or WiFi. The same JSON config, the same firmware hash, on both ends. Bind worked on the first power-on after that.
The Betaflight side was almost a side note. A few CLI lines:
serial 5 SERIAL_RX # tell Betaflight which UART is the receiver
set serialrx_provider = CRSF # speak CRSF
set serialrx_halfduplex = ON # single-wire serial
And then a few rounds of re-flashing the FC firmware (the backups/ directory has fourteen CLI dumps from this stretch — every meaningful change was preceded by a diff all > backup.txt so I could revert).
I learned more about ExpressLRS firmware versioning that week than I expected to learn about any RC protocol in my life.
The Raptor sidequest
Once the binding was sorted and the drone would arm reliably, I went after a more ambitious goal. Raptor is a pre-trained neural network policy for quadrotor control — an RL agent that maps [position, orientation, linear velocity, angular velocity, previous action] to four motor commands. The interesting thing about it, for this project, is that the RL-Tools repo ships a patched Betaflight build (raptor/rl-tools/embedded_platforms/betaflight/) that bakes the policy network directly into the firmware. In principle you flash that onto your FC, the policy takes over from PID control, and the drone just holds itself.
I built it. I flashed it. I plugged in a battery.
The drone immediately took off on its own and flew across my San Francisco apartment.
Not in a controlled way. Not "hovering, drifting a bit, asking to be tamed." Full haywire: pitching, spinning, ricocheting off the kitchen counter. I cut power as fast as I could, which means as fast as I could grab it out of the air. Then I picked up the props and tried to figure out what had gone wrong.
This is the point where the battery situation killed the experiment.
I had two batteries. Each one was good for maybe five minutes of flight. I didn't have a charger. So every aborted test was 5–10% of my total remaining attempts that day. After two or three more flashes — each one with a different observation-axis convention guess (FLU vs. FRD, body frame vs. world frame), each one ending with the drone trying to take off through a wall — I was out of batteries and out of patience.
The chain of guesses that needed to all be right at the same time was longer than I'd budgeted for: axis conventions, the rotation matrix layout the policy expected, what "position" meant when the drone had no GPS, the throttle normalization, the motor ordering for the AIR75 versus the one Raptor was trained on. Any one of these wrong and the policy thinks "I'm tilted 30° and falling" while the drone is sitting flat on the table, and the moment it has thrust it goes correcting in the wrong direction.
The Raptor sidequest got shelved.
What I never finished — the video link
This was the disappointment. Control is one RF link (2.4 GHz, ELRS). Video is a completely separate one (5.8 GHz analog, on the AIR75's onboard VTX). To close the loop I'd have needed a 5.8 GHz analog video receiver (a VRX), a way to get its composite output into my laptop, and a display path that didn't add 100 ms of buffer latency.
I had the VRX on order. By the time it arrived I was traveling. The drone went into a box. I haven't really opened it since.
What I should have done is buy the VRX in the same order as the drone. The cost of getting halfway through the project and stopping was a kit that's one focused half-day away from flying with a real video feed — and the half-day keeps getting deferred.
Reflections
Three things surprised me:
- Two devices speaking the same protocol doesn't mean they can talk. The Nano and the AIR75 were both ExpressLRS. Both had WiFi config pages. Both looked set up correctly. The actual constraint was that they needed to be on the same firmware build with the same binding phrase, and the only reliable way to guarantee that was to flash both from one tool. Until I did that, every individual piece of config looked fine, and nothing worked.
- CRSF is much simpler than I expected. It's a small serial protocol with a checksum and a packed-channel struct. Reading the elrs-joystick-control source is enough to fully understand it. The difficulty in this space isn't the protocol; it's everything around it — the RF layer, firmware versioning, the bind handshake.
- The hard part of a project like this isn't any single component. It's the interface between any two. The drone works. The TX works. The Xbox controller works. The bridge software works. Getting all four to share the same handshake is where you spend the week.
The drone's still in the box. I'd like to finish the video link some weekend. The control side made it through the gauntlet.