← Back to Projects

Flight Controls · Embedded Systems · State Estimation

Quadrotor Flight Controller

A 232 mm quadrotor built from scratch over a summer, with a flight controller written from the frame conventions up — and a sign-convention bug hunt that explains why it flies now.

Role Sole designer and developer
Timeline June – August 2026
Context Independent summer project
Bidirectional DShot Error-State KF Rotor-Rate Control Optical Flow Time-of-Flight Magnetometer System Identification ESP-NOW Telemetry

Built, flown, and instrumented.

Side view of the assembled quadrotor: stacked carbon decks with the flight electronics between them, a 1300 mAh 4S LiPo strapped on top, XT60 power lead, and four motors on tri-blade propellers.
Side profile — the stacked deck layout puts the power wiring below and the battery on top, with the flight electronics sandwiched between.
Head-on view of the custom transmitter: an ESP32 development board wired on a breadboard between two analogue thumbstick gimbals sitting along the lower edge, with red and blue pushbuttons.
The matching transmitter — an ESP32 reading two analogue gimbals for four axes, with calibration stored in NVS so stick centres survive a power cycle.

What is on the airframe, and how it fits together.

The stack is deliberately layered. Power enters at the bottom, computation sits in the middle, and the battery rides on top where its mass stays near the centre of the frame — which keeps the inertia the controller models close to the inertia it actually has.

  • Power. A 1300 mAh 4S LiPo feeds the frame through an XT60 lead into the distribution layer, which fans out to the four electronic speed controllers.
  • Propulsion. Four brushless motors on 5-inch tri-blade propellers, two spinning clockwise and two counter-clockwise so their reaction torques cancel in level flight. Each ESC talks bidirectional DShot, so the same wire that carries the command carries measured RPM back.
  • Sensing. An IMU supplies angular rate and specific force; a magnetometer anchors heading; a downward optical-flow sensor measures translation over the ground; a time-of-flight rangefinder supplies height. No single one of those is sufficient, which is why they are fused rather than used directly.
  • Compute and link. The flight controller runs the estimator and the full control cascade, and talks to the hand-built transmitter over a low-latency ESP-NOW link that also carries telemetry back to a laptop for logging.

The integration point that matters most is the RPM feedback. Because the ESCs report actual rotor speed, the controller can treat each rotor as a closed-loop actuator with a known output rather than an open-loop throttle whose real behaviour depends on battery sag, propeller loading, and temperature.

Two sheets, drawn from the firmware.

Both diagrams are generated from the pin assignments in the source, so the drawing cannot drift away from what the code actually does. Open either one full size for the detail.

Receiver wiring sheet: a 4S LiPo feeding a 15 V to 5 V buck converter and the ESC power pads, an ESP32-WROOM-32 devkit with MPU-6050, VL53L0X and QMC5883P on I2C and a PMW3901 optical-flow sensor on SPI, and four bidirectional DShot300 lines to a 4-in-1 45 A ESC driving four brushless motors.
Sheet 1 — receiver and flight controller. Power enters as 14.8 V and splits: straight to the ESC pads, and through a buck to 5 V for the ESP32. Every sensor shares the devkit's 3V3 rail, and each motor command line carries eRPM back on the same wire. Click to open the vector PDF
Transmitter wiring sheet: two analogue gimbals supplying throttle, yaw, roll and pitch to an ESP32-WROOM-32 devkit, with kill and on/off momentary buttons, two indicator LEDs behind 330 ohm resistors, and a 6 V battery pack into VIN.
Sheet 2 — transmitter. Four analogue axes off two gimbals, kill and arm buttons, status LEDs, and an ESP-NOW link running at 50 Hz to the airframe. Click to open the vector PDF
Two constraints on this board are easy to get wrong and expensive to discover in the air. The I2C bus is swapped from the ESP32's published default — the firmware calls Wire.begin(22, 21), so wiring to the datasheet pinout gives a dead bus. And the gimbal potentiometers must run from 3.3 V, not the 5–6 V pack: GPIO34 and GPIO35 are 3.6 V absolute maximum, so a 6 V wiper sits above both the ADC range and the pin rating.

Control on rotor angular velocity.

Rather than commanding motor throttle directly, the controller resolves everything down to a per-rotor angular velocity setpoint, then closes a speed loop on real RPM fed back over bidirectional DShot.

position → velocity → acceleration → tilt
tilt → body rate → angular acceleration → torque
(thrust, torque) → RotorAllocator → per-rotor ω setpoint
ω setpoint → RotorSpeedLoop (closed on RPM) → DShot

Why rotor angular velocity, and not throttle

A conventional flight controller ends its cascade at a throttle value per motor — a duty cycle handed to an ESC in the hope that it produces the intended thrust. That hope is the problem. Thrust from a propeller goes roughly as the square of its angular velocity, and the mapping from throttle to angular velocity is not fixed: it sags as the battery drains, shifts with air density, and changes as the propeller loads up in fast descent. A controller tuned at full charge is a differently tuned controller at half charge.

Commanding angular velocity instead removes that uncertainty. The allocator converts the desired thrust and three-axis torque into four rotor speed setpoints using the airframe's geometry, and each rotor then runs its own closed loop against the RPM the ESC reports back over bidirectional DShot. Battery sag becomes a disturbance the inner loop rejects rather than a gain error the outer loops never learn about.

The practical payoff is that every quantity above the allocator stays in physical units. The attitude loop asks for a torque in newton-metres, the position loop asks for a thrust in newtons, and the allocator is the single place where physics turns into actuator commands. When yaw authority was wrong, that separation is what made the fault findable: the torque request was correct and the mapping beneath it was not.

Conventions live in one file

A single header defines the body frame — +X right, +Y forward, +Z up — declares every rotation right-handed, and derives the IMU transform from that. Anything downstream that needs a sign asks the header rather than deciding for itself.

The mixer is derived, not written down

The allocation matrix is built at run time from rotor positions and spin directions by summing r × F, so a sign error in the mixer would have to be a geometry error first.

Drag torque identified in flight

The propeller drag-torque coefficient was never characterised for this airframe, so the controller identifies it live using normalised LMS, gated behind a commissioning flag plus checks on RPM freshness, excitation, allocator saturation, and attitude. It is hard-clamped to a physical band, so a bad estimate degrades to slightly-off yaw authority rather than a sign flip.

One wrong sign, five different symptoms.

Roll and pitch flew well. Yaw and horizontal position did not. The cause was a single variable holding a counter-clockwise-positive rate while its name said clockwise — the IMU is mounted upside down, and a gyro sign correction had quietly inverted it.

Four separate consumers each assumed clockwise and each broke differently: the yaw mixer turned into positive feedback and saturated, the magnetic-heading function returned the negative of the right-handed yaw it advertised and fed that to the state estimator, the two body/navigation frame transforms were written as each other's transpose — invisible at heading zero, fully cross-coupled at 90° — and the optical-flow lever-arm term injected fake translation that reinforced the error during rotation.

A fifth problem was found last and mattered most for the symptom: on the transmitter, the yaw command was a compile-time constant zero, read from no pin at all. The receiver was handed 0.0 on every packet. That alone would have prevented yaw control even with the other four fixed.

The hover log corroborates it: a yaw rate pinned near ±45°/s against a small opposing command is the signature of sign-inverted feedback, and horizontal position walked away from a fixed target and never returned.

An earlier diagnosis had correctly identified gating, signal-quality, and gain problems and fixed them. It never found the sign layer — and no gain change could have.

Telemetry from a hover run.

Logged live over the air at roughly 50 Hz. Two arm cycles appear in this run.

Four-panel plot from a hover flight: altitude tracking against a 0.5 metre setpoint, roll and pitch angles, yaw rate over time, and per-rotor angular velocity setpoints.
Altitude holds within a few centimetres of the 0.5 m setpoint; roll and pitch settle to roughly ±2°; the large yaw-rate oscillation in the first arm cycle is absent from the second; per-rotor setpoints sit near 1000 rad/s in steady hover.

Flown by hand from the transmitter.

A single 26-second flight, flown free-hand on the sticks. No waypoint list, no autonomous mode — the transmitter stick positions are logged on the same clock as the estimator and the controller, so every command can be traced through the cascade to what the aircraft actually did. In this run only heading is held to a setpoint; altitude and horizontal position are the pilot's, which is why position and heading tracking error are not meaningful numbers here.

Six stacked time plots over 26 seconds: normalised RC stick input for roll, pitch and throttle; body-frame velocity along X and along Y with commanded target against measured; roll and pitch angle with commanded against measured; and altitude.
The whole chain on one clock. Stick deflection at the top, the body-frame velocity it asks for below that, the tilt the controller chose in order to get that velocity, and altitude at the bottom. Reading straight down a column at any instant is reading the cascade.

A stick deflection is a velocity request, not a tilt

The sticks never command an angle. Roll and pitch map to a body-frame velocity setpoint of up to roughly ±0.5 m/s at full deflection, and the velocity loop decides what tilt is needed to produce it. Tilt is the means, not the command — which is why the velocity traces are smooth while the angle traces underneath them are busy. The attitude loop is continuously chasing whatever tilt the current velocity error demands.

Because the body frame is +X right, +Y forward, +Z up, the two channels split cleanly: the roll stick rolls the aircraft about its forward axis and moves it along ±X, right and left; the pitch stick pitches it about its right-hand axis and moves it along ±Y, forward and back.

Five stacked plots titled ROLL about +Y: stick position, body velocity target against measured, roll angle target against measured, body roll rate target against measured, and commanded roll torque.
Roll → sideways. Each full stick step asks for about ±0.5 m/s along body X, and the measured velocity gets there in roughly half a second. The angle panel below is the tilt the loop picked to do it — a few degrees, peaking near ±6°. Roll torque stays centred on zero.
Five stacked plots titled PITCH about +X: stick position, body velocity target against measured, pitch angle target against measured, body pitch rate target against measured, and commanded pitch torque.
Pitch → forward and back. Same structure along body Y, and the same half-second rise. The difference worth noticing is the bottom panel: pitch torque sits at a small positive bias rather than centred on zero, consistent with a fore–aft mass offset that the attitude loop trims out on every sample.

The circle is hand-flown, not planned

There is no circular trajectory anywhere in the firmware. The loop in the ground track is what comes out of driving the two sticks in quadrature by hand — roll right while easing pitch forward, then pitch back as the roll comes off. A circle fitted to the flown path lands at 0.542 m radius with 0.076 m rms residual, which measures how steady the pilot was rather than how well a controller tracked anything.

The panel on the right is the part that says the vehicle works. Every stick step produces a body-velocity response of the right sign and roughly the right magnitude, on both axes simultaneously, and the two axes stay decoupled while the aircraft is rotating through the turn.

Left: an overhead plot of the flown path forming a rough closed loop about one metre across, with a fitted circle of radius 0.542 metres overlaid. Right: roll and pitch stick position plotted against measured body velocity along X and Y over the same 26 seconds.
Left, the ground track as the estimator saw it, against a circle fitted to it. Right, the two sticks against the two body-velocity components they drive — the phase offset between the roll and pitch channels is what turns the flight into a circle.

Altitude converges on what the rangefinder measures

The first four seconds are a real hold command: 0.5 m, commanded from the ground, and the aircraft climbs onto it and settles. After that the throttle stick is in the pilot's hand and the dashed commanded trace vanishes underneath the solid measured one — with throttle active the setpoint is continuously re-anchored to the estimated height, so letting go of the stick leaves the aircraft holding whatever altitude it had reached. Commanded and measured differ by 0.085 m rms across the whole run, and nearly all of that is the opening climb.

Altitude in metres over 26 seconds: a dashed commanded trace at 0.5 metres for the first few seconds with the measured altitude climbing to meet it, after which the two traces coincide as altitude wanders between 0.25 and 0.85 metres. Annotation reads rms err 0.085.
Dashed is commanded, solid is the fused estimate. They are the same curve after the opening climb, which is what an altitude setpoint slaved to the measurement looks like.
The same flight replayed as a live dashboard: ground track and stick positions on the left, and on the right position, attitude with the commanded values overlaid, per-rotor speed as model demand against ESC-measured RPM, and the estimator's altitude and heading against the raw rangefinder and magnetometer.

What that looks like from outside.

A short clip of the aircraft flying on the stabilisation described above. A longer walkthrough of the transmitter and the flight controller is in progress; this one is just the vehicle doing its job.

All four axes are live on the sticks. Throttle takes it up and down, pitch drops or raises the nose and translates it forward and back, roll banks it and translates it sideways, and yaw rotates it about the vertical without moving it across the ground. None of those are direct commands to the motors — each one is a request that ends up as four rotor speed setpoints closed on measured RPM.

The part worth watching is what happens when the sticks come back to centre. The aircraft stops and stays. Nothing outside the airframe is telling it where it is: the downward optical-flow sensor measures how the ground slides underneath it, the time-of-flight rangefinder measures how far below that ground is, and the estimator fuses both with the IMU into a position and a height it can hold against. Capturing a spot and sitting on it is that fusion working, not the pilot trimming.

Backyard flight test — climb out, translation on pitch and roll, and position hold on optical flow and the rangefinder between inputs.