BBOT — how a self-balancing robot works
A two-wheeled inverted-pendulum robot: an ESP32, two stepper motors, an inertial sensor, and the control code that keeps an unstable thing upright.
Source on GitHub · README (the full manual) · flight-log design doc
This page walks through the whole machine, from "why does it fall over?" to "how do you know why it fell over?". The moving figures are not videos: they run a JavaScript port of the actual firmware's controller and of the physics model from its test suite, with the same gains the robot ships with. Where this page explains a concept, the README carries the reference detail — full command table, bring-up checklist, troubleshooting.
The inverted pendulum problem
Stand a broom upright on your open palm and it starts to topple. To keep it up, you slide your hand under the falling top — over and over, faster than you can consciously think. That is the entire job of this robot. The "hand" is a pair of wheels, and the "brain" is a $5 microcontroller.
Control engineers call this shape an inverted pendulum: a mass balanced above its pivot. It is unstable by nature — any lean, however small, grows into a fall unless something actively corrects it. There is no mechanical trick that makes it stand; the only thing holding it up is software reacting to every wobble, 200 times a second.
- ESP32 dev board
- A dual-core 240 MHz microcontroller with WiFi. One core does nothing but balance; the other does all the communicating (§6).
- MPU6500-class IMU
- A motion sensor combining an accelerometer and a gyroscope, read over I²C at 400 kHz. It is the robot's inner ear (§3). This particular board answers
WHO_AM_I = 0x74— a 6500-family clone, expected and handled. - Two NEMA17 stepper motors on DRV8825 drivers
- Steppers move in exact, discrete steps — at quarter-microstepping, 800 per revolution — so the code always knows precisely how far each wheel has turned, without encoders. One motor is mounted mirrored; the firmware negates its direction.
- 70 mm wheels, 3S LiPo battery
- Each step moves the robot about 0.275 mm. The battery supplies roughly 11–12.6 V depending on charge.
Why acceleration, not speed
The obvious control law is: if it leans forward, drive the wheels forward — more lean, more speed. It feels right, and it is how most first attempts get written. It also cannot work. Driving at a speed proportional to lean can only slow a fall, never stop it: the robot tips further, the wheels go faster, and it face-plants at full throttle.
What actually catches an inverted pendulum is wheel acceleration. Accelerating the base is what moves the wheels back underneath the falling centre of mass — the same reason you jerk your palm sideways to save the broom rather than sliding it at a steady rate. So the controller's output is a wheel acceleration, in steps per second squared, which the firmware integrates into a speed command every tick:
// inner loop: tilt error → wheel ACCELERATION (steps/s²) last_accel = inner.update(pitch_error, dt, gyro_dps); // integrate that acceleration into the speed command: motor_cmd += last_accel * dt;
The stakes have a number. With these wheels and this geometry, gravity accelerates a falling robot at about 623 steps/s² per degree of tilt. The controller's proportional gain must comfortably beat that or it literally cannot out-accelerate gravity. The shipped value is Kp = 2000 — roughly three times gravity's pull.
This distinction is guarded by a regression test. test_speedmode_structurally_diverges runs the old speed-proportional law against the project's physics model and asserts that it falls over — if anyone ever reverts the architecture by accident, the test suite refuses to pass (§13).
Sensing: two flawed sensors, one estimate
Before the robot can correct a lean it has to measure it. Neither sensor on the IMU can do that alone:
| Sensor | Measures | Strength | Flaw |
|---|---|---|---|
| Gyroscope | rotation rate (°/s) | fast, smooth — integrate it and you get angle | tiny errors accumulate; after a minute it believes a lean that isn't there |
| Accelerometer | direction of gravity | always knows true "down" — no drift | every bump, vibration and motor jolt rattles the reading |
The fix is a complementary filter, and it is one line: trust the smooth gyro in the short term, and let the drift-free accelerometer pull the estimate back toward truth over the long term.
pitch = 0.99 * (pitch + gyro_rate*dt) + 0.01 * accel_angle;
99% gyro, 1% accelerometer, 200 times a second. Fast movements ride the gyro; slow drift is quietly erased by gravity. The result is an estimate that is both responsive and honest.
One subtlety the real firmware handles: when the wheels accelerate hard, the accelerometer cannot distinguish that push from gravity, so it under-reads the tilt. The firmware feeds the commanded wheel acceleration back into the estimate to cancel the artifact — capped at 0.20 g, so a stuck wheel can never fool the filter into a large false correction. That cap exists because of a real crash, and a regression test now pins the behaviour.
Control: a PID loop, then a second one
The robot now knows its lean. Turning "I'm 2° forward" into "accelerate the wheels this hard" is the job of a PID controller — proportional, integral, derivative: three reactions to the same error, summed.
| Term | Answers | Role here |
|---|---|---|
| P — proportional | how far off am I? | The muscle: push back in proportion to the lean. Too little and it can't catch a fall; too much and it overshoots into a wobble. |
| I — integral | how long have I been off? | Leans on persistent small errors until they vanish. Used gently here — the outer loop does most of the steady-state work. |
| D — derivative | how fast is it changing? | The damping. This firmware feeds the gyro's rate measurement straight in, rather than differentiating the angle — cleaner, and immune to derivative kick. |
Why two loops?
A single PID can keep the robot upright — but upright while slowly rolling across the room, because its true balance point is never exactly where the configuration says. So the firmware stacks a second, slower loop on top. The inner loop (200 Hz) turns tilt error into wheel acceleration and keeps the robot upright. The outer loop (50 Hz) watches wheel speed and nudges the target lean angle — by at most ±3° — to brake the robot back toward standing still. Rolling forward → lean the target back a fraction → the inner loop brakes → the robot returns home. It is the difference between "standing" and "standing still".
Actuation: pulses at 20 kHz
The controller ends each tick with a number like "run the wheels at 1,340 steps per second." Stepper motors move one discrete step per electrical pulse, so something must emit exactly the right pulse train — and never stutter, no matter what the rest of the code is doing. A late pulse is a lost step, and enough lost steps is a fall.
Rather than use a stepper library, the firmware runs a small pulse engine inside a hardware timer interrupt that fires 20,000 times a second. Each tick, a per-motor phase accumulator adds the desired step rate to a running total; when the total crosses 20,000 it emits one pulse and subtracts 20,000. A fast rate fills the bucket quickly and pulses often; a slow rate pulses occasionally. The long-run average frequency comes out exact, with no floating point in the interrupt — the same trick as Bresenham's line algorithm.
Because the engine lives in a timer interrupt, pulse timing stays exact even if the main loop stalls on a sensor read — the classic failure of loop-driven stepper libraries. One courtesy detail: when a wheel reverses, the engine skips a single tick so the direction pin can settle before the next step edge, which the DRV8825 driver quietly requires.
Two cores, one job each
On a typical Arduino, printing a line of debug text can stall the program until the serial buffer drains. For a blinking LED that's harmless. For a robot that must issue a fresh correction every 5 milliseconds, one stalled print is a missed correction, and a missed correction can be a fall.
The ESP32 has two CPU cores, and the firmware splits them cleanly. Core 1 only balances: sensor read, filter, PID, motor command, 200 times a second, forever. It never touches WiFi, serial, or flash — nothing that can make it wait. Core 0 does all the talking: USB, WiFi, the web page, flash writes. If core 0 stalls, the robot goes quiet for a moment but keeps balancing.
The cores never wait on each other. They communicate only through small lock-free ring buffers in shared memory: core 1 writes a whole record and moves on immediately; if a ring is full the record is dropped and counted — never half-written, never blocking. Commands travel the other way through a matching queue. The practical consequence: WiFi dropping, the router rebooting, a phone wandering out of range — all of it changes only whether you can see the robot. It balances regardless, because the radio is never in the control path.
The state machine
A machine with spinning wheels needs to be explicit about when it is allowed to act. The firmware is built around a small state machine with one governing rule: the robot never arms itself. Powering it on, rebooting it, or picking it up off the floor never starts the motors — only a deliberate, physical gesture does.
That arming gate — upright, still, briefly — doubles as an "I'm ready" handshake between operator and machine. Brief tremors under 0.1 s pause the hold rather than resetting it, so a human hand can actually complete it.
Failsafes
Two spinning wheels and a lithium battery deserve layers of "when in doubt, stop". Each failsafe targets a specific way the world can stop matching the controller's assumptions:
- Fall cutoff. Past 30° of tilt the fall is unrecoverable; the motors stop rather than thrash on the floor.
- No-authority watchdog ("RailWatch"). If the speed command sits pinned at its maximum for 1.5 s, the wheels clearly aren't affecting the tilt — the robot is being held in the air, or the motor power is off. It disarms, so it can't wind up and bolt the moment the wheels regain traction.
- IMU failsafe. If the motion sensor stops delivering samples or the I²C bus fails repeatedly, the robot stops instead of balancing blind on stale data.
- Kill switch. The
Kcommand — from serial, WiFi, or the phone page — disarms instantly and latches SAFE (§7).
Wireless
Early on, tuning meant chasing the robot across the floor with a laptop while the USB cable tugged on the very balance being tuned. So the project cut the cord: every command works identically over USB serial, WiFi/UDP, and the phone page's WebSocket — the command handling is transport-agnostic — and the cable is only needed for first-time setup and disaster recovery.
WiFi credentials are stored once, over USB, into the ESP32's non-volatile storage. They never appear in the source code or the repository. If no credentials are stored — or the home network can't be joined within 20 seconds (wrong password, out of range) — the robot raises its own access point instead: join the bbot network and everything works at 192.168.4.1, no infrastructure needed.
credentials stored once over USB → robot joins as a station → reachable at bbot.local
no credentials, or 20 s without joining → robot raises SSID bbot → reachable at 192.168.4.1
Two practical facts round out the radio story. The ESP32 only speaks 2.4 GHz — a 5 GHz-only network is simply invisible to it, and the firmware ships a scan command (NS) that reports whether the configured network is actually visible, because that failure looks confusingly like a wrong password. And firmware can be flashed over the air (pio run -t upload -e esp32ota): the robot accepts an OTA push only while stopped — an attempt mid-balance times out — and reboots into the new firmware with its saved tuning intact.
Driving it from a phone
The robot hosts its own driving interface — a single web page served from the ESP32 itself, no app to install. Open http://bbot.local (or 192.168.4.1 on the robot's own network) and the phone becomes the remote: status flows out and commands flow back over a WebSocket.
The screen is a pure function of the robot's state and flips on its own. While balancing, it is a full-screen joystick: drag up to drive forward, sideways to turn; release and it springs back to stop. While stopped, it becomes a launch instrument: a live pitch marker riding above or below the arming band, which fills as the 0.4 s upright-and-still hold accumulates — so you can watch your own hand complete the arming gesture from §7.
The deadman is the real safety. If no drive command arrives for 450 ms — locked phone, dead browser, out of range — the firmware ramps the drive setpoints to zero on its own and the robot coasts to a balanced stop. Crucially this timer lives on the control core, so a wedged radio task can stall the telemetry but can never defeat the deadman.
The same never-block discipline from §6 applies to the page's status feed: if a phone's connection is too congested to accept a status frame, the frame is dropped for that client (the page briefly shows a "stale" pill) rather than letting a blocked send tie up the communications core. Drive feel — top speed, turn rate, stick response — is shaped in the firmware, not the page, so it is identical over every transport, live-tunable, and saved with everything else.
Telemetry and the flight log
Watching the robot live is deliberately lightweight: five times a second it prints one human-readable status line — state, pitch, gyro, motor command, health counters — identically over USB and WiFi. State changes (arming, falls, failsafes) get their own S, lines, and every command is acknowledged. That thin stream is enough to drive and tune by.
But a 5 Hz line can't explain a fall that develops in 50 milliseconds. For that, the control loop separately records one fixed 32-byte snapshot every single tick — 200 per second, always, whenever the robot is powered — into a RAM ring that the communications core drains to on-board flash. After a session you pull the log over HTTP and decode it offline into a CSV: every tick's pitch, estimate, motor command, per-wheel steps, and health flags, with nothing missing around the interesting moments. There is no "start recording" step to forget.
Why the recorder writes raw flash instead of using a filesystem: erasing a flash sector on the ESP32 suspends the flash cache and freezes both cores for the whole ~40 ms erase — the balance loop included, on this robot a measured fact and a guaranteed topple. A filesystem erases whenever it needs space, i.e. mid-write. So the recorder only ever programs pre-erased pages (≤2 ms, harmless), and a separate eraser prepares empty 64 KB segments strictly while the robot is stopped, keeping about 100 seconds of pre-erased runway ahead of the writer. A stand that outruns the runway pauses recording and counts the gap — it never erases mid-balance. The same rule defers every settings save to flash until the robot is set down.
The records also carry the yaw and roll gyro rates. When the robot is balancing straight, both wheels get identical commands — so any yaw in the log means the wheels responded unequally to equal drive: slipping, lost steps, or a mechanical fault, visible in data that pitch-only capture would miss.
Building one
Everything is off-the-shelf and the firmware has a single external library (the WebSocket server for the phone page) — the IMU driver, stepper timing, and control code are all in the repository. The README has the complete bring-up checklist and troubleshooting table; this is the shape of it.
| Signal | GPIO |
|---|---|
| IMU SDA / SCL (I²C) | 33 / 32 |
| IMU INT (data-ready) | 18 |
| Left motor STEP / DIR | 27 / 25 |
| Right motor STEP / DIR | 14 / 26 |
pio run # build the firmware pio run -t upload # flash over USB (motor power OFF) pio run -t upload -e esp32ota # flash over WiFi (robot lying down) pio test -e native # 114 host-side tests, no hardware pio device monitor # live telemetry at 921600 baud
Bring-up, in order
Each step removes a failure mode that would otherwise masquerade as a tuning problem:
- Set the driver current — 0.4–0.6 V on the DRV8825 reference pin for a robot this light. Too low and wheels stall under load; too high and the drivers overheat mid-balance.
- Motor test (
M) — both wheels must roll the robot forward, one full revolution in 2 s. A backward wheel means flipping a config flag, never rewiring or "fixing" it with gains. - Check the signs — tip the robot forward: pitch and gyro rate must both read positive. A wrong sign here makes every later step incomprehensible.
- Capture the balance point — the single most important number (next paragraph).
- Climb the tuning ladder — one symptom, one knob, one change at a time.
The balance point
The pitch the sensor reads when the centre of mass is exactly over the axle is a physical property of the frame — on this build about +4.5°, dominated by the tilted IMU mount — and half a degree of error shows up as a steady drift across the room. Rather than measure it by hand, the firmware captures it: get the robot balancing, let it settle, and send AZ ("zero here"). It snapshots the average pitch it has actually been balancing at and saves it permanently — refusing unless the robot has been genuinely steady for a second, so a wobble can never freeze in a bad number. All tuning persists the same way: S saves gains and limits to flash, with the physical write deferred until the robot is stopped, for the flash-stall reason in §11.
The tuning ladder
| Symptom | Knob | Direction |
|---|---|---|
| Leans and creeps steadily one way | A (offset) | move toward the lean, 0.3° at a time |
| Slow and mushy, falls "like it isn't trying" | P | up ~25% at a time |
| Fast trembling or audible buzz at rest | D | down |
| Rocking at 1–2 Hz that grows until it falls | D | up; if it worsens, P down |
| Stands but wanders around the room | OP (outer) | up |
| Wheels squeal or skip during hard saves | LA (accel clamp) | down — the steppers are slipping |
All of these are single-letter commands, live over serial, WiFi, or the phone's settings screen — no reflashing. The full command table is in the README. The golden rule: change one thing at a time, watch the telemetry, save the keepers.
Testing without hardware
The control math is deliberately pure — no hardware calls — so the same headers that run on the ESP32 compile and run on a PC. The project wraps them in a real inverted-pendulum physics model and runs 114 automated tests that balance a virtual robot, shove it, miscalibrate it, and jitter its timing, all before any hardware is at risk.
// a wheeled inverted pendulum, wheels modelled as velocity sources: L·θ̈ = g·sin(θ) − ẍ·cos(θ) // run the SHIPPED gains through the SHIPPED controller // and assert it stands. The figures on this page run this same loop.
The most valuable test is a tripwire: it runs the broken "speed proportional to tilt" law from §2 against the physics and asserts that it falls over. Its failure message reads "speed-mode law balanced the real plant?! (architecture regression check)" — the design decision at the heart of the robot, encoded as a test that screams if it's ever reverted.
| Suite | Covers | tests |
|---|---|---|
| controller | PID, clamping, anti-windup, calibration stillness gate, RailWatch | 27 |
| filter | complementary-filter convergence, signs, noise | 16 |
| cascaded | full two-loop controller vs. the physics model, incl. the speed-mode tripwire | 16 |
| drive | joystick shaping, slew limits, deadman expiry | 11 |
| telemetry | lock-free rings, command queue, status seqlock | 21 |
| flight log | record packing, fixed-point scaling, timestamp unwrap | 23 |
Where the project stands
Honestly: it balances, and it is still being tuned. On the current tune the robot stands unaided for a minute or more and recovers light pokes; a hard shove can still start a growing oscillation that ends in a fall. The architecture described on this page — the sensing, the cascade, the dual-core split, the recorder — is done and tested; the remaining work is climbing the tuning ladder with the flight log as the feedback loop, turning "stands for a while" into "shrugs off a shove and settles."
Some upgrades are deliberately not being made yet: a Kalman filter, wheel encoders, motor current sensing, LQR control. Each is the right tool only if a specific limitation shows up in the data — estimator error under disturbance, wheel slip, torque loss — and the simple complementary-filter-plus-cascade demonstrably stands. Adding sophistication before the data demands it would just add places for bugs to hide.