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.

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.

BALANCING
click to shove
pitch0.0°
motor cmd0
wheel travel0.00 m
loop200 Hz
the simulation runs the firmware's controller at 200 Hz
The robot balancing in real time. Click or drag anywhere on it to give it a shove; it rolls under the push, catches itself, and returns to rest. If it falls, it re-arms after a moment.

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.

The anatomy: a roughly 30 cm tower with its centre of mass about 120 mm above the wheel axle. Everything below explains how these four parts cooperate.
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:

include/controller.h — the inner loop's output, and the integration
// 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.

BALANCING
stable
The same robot under both control laws. Switch the toggle to "speed ∝ tilt" and watch it lose: each correction arrives as a velocity, never a push, and the lean compounds until it falls.

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:

SensorMeasuresStrengthFlaw
Gyroscoperotation rate (°/s)fast, smooth — integrate it and you get angletiny errors accumulate; after a minute it believes a lean that isn't there
Accelerometerdirection of gravityalways knows true "down" — no driftevery 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.

include/controller.h — the fusion, once per 5 ms tick
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.

—— true angle – – gyro only (drifts) ···· accelerometer only (noisy) —— fused estimate
Sensor fusion under adjustable abuse. Increase the gyro's drift or the accelerometer's noise: each raw signal wanders or shakes, while the fused estimate stays close to the true angle.

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.

TermAnswersRole here
P — proportionalhow 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 — integralhow 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 — derivativehow 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.
BALANCING
balancing
The cascaded controller with live gains, and a strip chart of pitch (green) against motor command (grey, dashed). Set Kd to zero and it trembles; raise Kp far enough and it turns twitchy; switch the outer loop off and it stays upright but slowly wanders away.

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".

Outer loop · 50 Hz — filtered wheel speed → target lean angle (clamped to ±3°)
↓ sets the setpoint for
Inner loop · 200 Hz — tilt error → wheel acceleration (clamped ±20 000 steps/s²) → integrated into a speed command
↓ commands
Step generator · 20 kHz — exact pulse timing to both motors (§5)
The cascade. Anti-windup and derivative-on-gyro live in the same PIDState struct that runs on the robot — and in this page's simulations.

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.

The phase accumulator at work (slowed enormously so pulses are visible). The bucket fills at the commanded rate; every overflow fires one STEP pulse to the motor driver.

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.

Traffic between the cores. Telemetry records (squares) flow from the control loop through a ring buffer out to USB and WiFi; commands (dots) flow back through a queue. Neither side ever waits for the other.

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.

CALIBRATING — boot: ~2 s measuring gyro bias while the robot is held still (a jostled attempt is rejected and retried)
↓ bias measured — motors stay off
FALLEN — motors off, waiting for the operator
↓ held within ±2.5° of the balance point, rotating under 3 °/s, for 0.4 s
BALANCING — the 200 Hz loop is live
↓ tilt past 30°, or any failsafe (§8) → back to FALLEN
SAFE — the kill command (K) from any state latches here: motors locked, never auto-arms. Leaving it (U) only returns to FALLEN — the upright-and-still hold is still the sole path back to balancing.
The states and their transitions. Arming is the deliberate gesture: hold the robot steady near its balance point for 0.4 seconds.

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:

BALANCING
balancing
The failsafes, exercised. A hard enough shove exceeds the 30° cutoff; the kill command latches the motors off; re-arming replays the upright-and-still hold. The log shows the state-change lines the real robot prints.

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.

Home network (preferred)
credentials stored once over USB → robot joins as a station → reachable at bbot.local
Access-point fallback
no credentials, or 20 s without joining → robot raises SSID bbot → reachable at 192.168.4.1
Both paths serve the same things: the drive/settings page, flight-log fetch, and OTA flashing.
Two ways to reach the robot. The fallback means it is drivable in a field with no router in sight.

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.

WHILE BALANCING
joystick — drag to drive, release to stop
⇄ flips automatically with state
WHILE STOPPED
launch instrument — pitch marker and arming band
The two faces of the phone page. There is no manual toggle; the robot's state decides which screen you get. A settings screen exposes every live-tunable value, fed by structured status data rather than scraped text.

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.

status line · 5 Hzseq 0
The live status feed, generated from a simulation running elsewhere on this page — pitch and motor command charted above the raw lines. This is what watching the real robot looks like.

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.

Core 1 · control loop — pushes one 32 B record per 5 ms tick (if the ring is full: drop and count — a 5 ms gap, never corruption)
↓ lock-free ring in RAM
Core 0 · log writer — programs records into pre-erased flash pages (≤2 ms each, safe mid-balance)
↓ rotating 64 KB segments, raw on the data partition
Retention — roughly the last 2 minutes; each segment carries a header with the full tuning snapshot, so it decodes standalone
The stopped-only eraser reclaims old segments and keeps ~100 s of pre-erased runway. Fetch: robot stopped → python tools/flog_decode.py --fetch → CSV.
The flight-log write path. Full rationale and format details are in the design doc (note: it predates the final raw-partition store; the README describes what ships).

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.

SignalGPIO
IMU SDA / SCL (I²C)33 / 32
IMU INT (data-ready)18
Left motor STEP / DIR27 / 25
Right motor STEP / DIR14 / 26
PlatformIO — build, flash, test, watch
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:

  1. 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.
  2. 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.
  3. Check the signs — tip the robot forward: pitch and gyro rate must both read positive. A wrong sign here makes every later step incomprehensible.
  4. Capture the balance point — the single most important number (next paragraph).
  5. 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

SymptomKnobDirection
Leans and creeps steadily one wayA (offset)move toward the lean, 0.3° at a time
Slow and mushy, falls "like it isn't trying"Pup ~25% at a time
Fast trembling or audible buzz at restDdown
Rocking at 1–2 Hz that grows until it fallsDup; if it worsens, P down
Stands but wanders around the roomOP (outer)up
Wheels squeal or skip during hard savesLA (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.

test_native_cascaded — the plant the tests (and this page) balance
// 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.

SuiteCoverstests
controllerPID, clamping, anti-windup, calibration stillness gate, RailWatch27
filtercomplementary-filter convergence, signs, noise16
cascadedfull two-loop controller vs. the physics model, incl. the speed-mode tripwire16
drivejoystick shaping, slew limits, deadman expiry11
telemetrylock-free rings, command queue, status seqlock21
flight logrecord packing, fixed-point scaling, timestamp unwrap23

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.