Reaction Wheel Balancer
An ESP32 + SimpleFOC reaction-wheel robot that balances on one edge through pure momentum exchange, no ground contact.
I'd spent a lot of time doing reaction wheel control in simulation for satellite ADCS work, and at some point I wanted to feel it in real hardware instead of on a screen. Not a two-wheeled robot that balances by driving its wheels along the ground, that's a different, more forgiving problem. I wanted something that balances the way a spacecraft actually controls itself: purely through angular momentum exchange, no ground contact doing any of the work. That's how the reaction wheel balancer happened, a small triangular robot that stands on one edge and stays there by spinning a single wheel.
The physics of balancing with nothing to push against
A two-wheeled balancing robot has it relatively easy. Its wheels are in contact with the ground, so it can apply a real reaction force against the world any time it needs to correct. A reaction wheel robot has no such luxury. The only actuator is a flywheel that exchanges angular momentum with the body it's mounted to, and conservation of angular momentum means the total never changes, only how it's distributed between body and wheel. Spin the wheel one way and the body reacts the other way, precisely the same principle a spacecraft reaction wheel uses, just with gravity actually trying to tip the whole thing over instead of leaving it to drift in orbit.
That makes this an open-loop unstable system with a genuinely constrained actuator: available control torque is capped by wheel inertia and motor torque headroom, and there's no way to get rid of momentum once you've built some up, only move it around. That last part turns out to be the whole story of this project, more on it below.
Sensing the tilt
An MPU6050 (accelerometer plus gyroscope) gets fused into a tilt angle and tilt rate with a complementary filter, the standard cheap fusion trick: the gyro is accurate over short timescales but drifts, the accelerometer is noisy on short timescales but has no drift, so blending them by frequency (trust the gyro for fast changes, trust the accelerometer to correct slow drift) gets you a clean estimate without needing a full Kalman filter for a problem this small. Gyro bias gets auto-calibrated at boot.
One detail that saved me real time later: which raw IMU axis maps to "up" versus "pivot," and their signs, lives in a config file rather than hardcoded into the control code. The IMU's silkscreen axes never quite line up with the robot's own axes once it's actually mounted, so remounting the board or rebuilding the frame only ever needs a config edit, not a code change.
Driving the wheel
Actuation is an AS5600 magnetic encoder for shaft angle, feeding both FOC commutation and wheel-speed feedback, and a 2804 gimbal BLDC motor run through SimpleFOC in voltage torque mode. Gimbal motors like this one have winding resistance that dominates over inductance at these speeds, so commanded voltage tracks torque well enough without needing current sensing at all, one less sensor to get right.
The control law itself is a single state-feedback line, computed every loop:
with (volts) handed straight to the motor driver. The controller arms itself once the body has been held near vertical for a short settle time, and disarms if it tips past a fall threshold, so it doesn't fight a robot that's already fallen over.
Why there's a third gain
The and terms are the obvious ones, drive tilt angle and tilt rate toward zero. The term is the one that only makes sense once you internalize the "no way to dissipate momentum" point from earlier. At exactly zero tilt, the wheel can sit at any constant speed at all with zero motor torque, since there's no gravity torque to balance. That means without any feedback on wheel speed, any small disturbance nudges the wheel and it just keeps slowly accelerating from there with nothing pulling it back, until it eventually saturates and stops being useful.
feeds back a small counter-torque proportional to wheel speed, which bleeds that speed off over time at the cost of a slight steady-state tilt (the robot leans very slightly to keep the wheel desaturating). Keeping an order of magnitude below and means it does that job without meaningfully fighting the angle hold. Because for state is exactly the LQR form, I can either hand-tune the three gains live over serial or linearize the body-wheel system about vertical and solve for an optimal in Python. Same firmware either way, just a different route to the same three numbers.
Two crashes that turned out to be two different bugs
Reliability ended up needing as much attention as the control law itself. The firmware runs a hardware watchdog that reboots the MCU and de-energizes the motor driver if the main loop ever stalls, so a freeze is bounded instead of needing someone to walk over and power-cycle it. But "add a watchdog" only gets you a robot that recovers from freezes, not one that stops freezing, so I still had to find the actual causes.
Decoding crash addresses against the real build (addr2line against the compiled .elf) turned up two genuinely unrelated problems that had been presenting as one vague "it randomly freezes" symptom. One was blocking Serial writes stalling when nothing on the other end was draining the USB buffer. The other was a real I2C driver lockup that a Wire.setTimeOut() call can actually catch. The watchdog is what recovers from the second one in practice, but finding two separate root causes instead of stopping at the first plausible one was the part that mattered. Isolating them needed a debug flag to poll only the IMU or only the encoder at a time, so I could tell whether a given fault tracked to one sensor's bus traffic instead of assuming it was all one thing.
Sign conventions were their own quieter source of bugs. Whether theta grows in the direction the robot is actually tipping, and whether the wheel spins to correct a tip or accelerate it, are two independent signs that live in different config fields, and a mistake in either one looks identical to a stranger watching the robot twitch and fall over. Only tracing through which sign controls which behavior separately caught both.
What actually held up
The finished robot reliably self-balances from a near-vertical starting position and shrugs off a moderate push without falling, holding stable closed-loop control purely through reaction-wheel momentum exchange while keeping the wheel itself from creeping toward saturation. Tuning a real embedded control loop turned out to be a genuinely different exercise from tuning one in simulation. Sensor noise, actuator saturation, and timing jitter all show up in ways that are trivial to hand-wave away on a screen and impossible to ignore on a desk. Giving the mechanical platform generous reaction-wheel headroom up front made the whole control problem meaningfully easier than a tightly mass-constrained design would have been, and the two crash causes were a good reminder that "it randomly freezes" is almost never one bug. Decoding the actual fault address instead of guessing split it cleanly into two unrelated problems with two unrelated fixes.