How to Build a Self-Balancing Rover with ESP32
IMU-based stabilization with live battery and tilt stats on OLED
Updated

What you'll build
This guide builds a self-balancing two-wheeled rover: an ESP32 reads tilt from an MPU6050 IMU, feeds the angle into a PID control loop, and drives two DC gear motors through an L298N H-bridge to keep the chassis upright. A small SSD1306 OLED displays live tilt angle, battery voltage, and the three PID gain values so you can watch the control system at work without opening a serial monitor.
PID control is one of the most important concepts in robotics. A self-balancing robot is the classic way to develop intuition for it because the feedback is immediate and physical: you can see the rover overshoot, oscillate, or drift and directly connect that behaviour to specific gain values. The build also covers reading raw accelerometer data over I2C, converting it to an orientation angle with atan2, and managing two motor channels with PWM through ledcAttach.
The same MPU6050 used here also appears in the fitness wristband, where accelerometer data drives step counting rather than balance control.
What you are building
The firmware has four main jobs:
- Read accelerometer events from the MPU6050 over I2C (SDA GPIO21, SCL GPIO22) and compute a tilt angle using
atan2(accel.x, accel.z), - Run a PID loop at roughly 100 Hz: compute proportional, integral, and derivative terms from the tilt error relative to
targetAngle(0.0°) and sum them into a motor control signal, - Drive the L298N via four PWM channels on GPIO25 (IN1), GPIO26 (IN2), GPIO32 (IN3), GPIO33 (IN4) using
ledcAttachat 5 kHz / 8-bit resolution, - Read battery voltage from the voltage divider on GPIO36 (BATT_PIN, input-only) and refresh the SSD1306 OLED every loop cycle with angle, control output, battery voltage, and the current
kp,ki,kdvalues.
Remote control, obstacle avoidance, and multi-mode driving are outside the scope of this build.
Upload and calibrate
Open the project in Schematik and click Deploy using Chrome or Edge. Once uploaded, open Serial Monitor at 115200 baud. You should see Rover ready within a second of boot.
Stand the rover upright by hand and read the tilt angle on the OLED. If your IMU is not perfectly vertical on the chassis, note the resting angle and set targetAngle to that value instead of 0.0.
The three gain constants in the firmware are:
kp— proportional gain, currently 24.0: controls the immediate response to tilt. Too low and the rover falls; too high and it oscillates.ki— integral gain, currently 0.4: corrects slow drift. Keep this small untilkpandkdare stable.kd— derivative gain, currently 0.85: damps rapid changes. Increase this if the rover oscillates at a fixed frequency.
Start with ki = 0 and kd = 0 and raise kp until the rover reacts but oscillates. Then add kd to damp the oscillation. Add a small ki last to remove any steady-state lean. Expect to iterate several times; the right values depend on your motor gear ratio, wheel radius, and chassis weight distribution.
Troubleshooting
- OLED stays blank: confirm I2C address is
0x3C. Some SSD1306 modules use0x3D. Both devices share the bus, so if one is wrong the whole I2C bus may lock. - MPU6050 not found on boot: Serial Monitor prints "MPU6050 not found" if the library cannot reach the sensor. Check SDA/SCL wiring and confirm the module has power. The default I2C address is
0x68; if AD0 is pulled high it becomes0x69. - Motors spin in the wrong direction: swap the two wires on one motor terminal, or invert the IN1/IN2 (or IN3/IN4) logic in
driveMotors(). Both corrections work; changing the code is cleaner for a permanent build. - Rover falls immediately without balancing: the PID gains need tuning for your specific chassis weight. Start with
kp = 24,ki = 0,kd = 0and place the rover on a flat surface; if it tries to correct but oscillates, addkd. If it barely reacts, raisekp. - Battery voltage reads wrong: check that the two divider resistors are equal and that their junction goes to GPIO36. The firmware computes
battV = analogRead(BATT_PIN) / 4095.0 * 3.3 * 2.0— adjust the final multiplier if your divider ratio differs. - ESP32 resets during motor starts: the motor supply and the ESP32 must share a common GND. If they are isolated, inductive spikes from the motors can reset or damage the board.
Scaling to a full platform
Once balance is reliable, the motor PWM outputs can be modulated by a remote-control input — Bluetooth serial is the easiest addition on this hardware. Replacing the single-axis atan2 angle estimate with the MPU6050's built-in DMP (digital motion processor) gives a more stable orientation reading under vibration. An ultrasonic sensor on the front mirrors the parking-sensor build and adds obstacle awareness without changing the balance logic.
Wiring diagram
Components needed
| Component | Type | Qty | Buy |
|---|---|---|---|
| GY-521 MPU6050 6-DOF Gyroscope and Accelerometer Module | sensor | 1 | €4.50 |
| Grove OLED Display 0.66" (SSD1306) | display | 1 | $5.50 |
| Full-Bridge Motor Driver Dual - L298N | actuator | 1 | $7.24 |
Supplier links, prices, and availability are shown as a guide and may change. Schematik may earn a commission from purchases made through affiliate links.
Assembly
Connect IMU and OLED on I2C bus
Wire MPU6050 and SSD1306 VCC to 3.3V, GND to ground, SDA to GPIO21, and SCL to GPIO22. Both devices share the same I2C bus.
- Keep I2C wires under 15 cm for stable high-speed communication.
Wire motor driver control lines
Connect L298N IN1–IN4 to ESP32 GPIO25, GPIO26, GPIO32, and GPIO33. Connect L298N GND to ESP32 GND.
- Enable the onboard 5V regulator jumper on the L298N to power the ESP32 from the motor battery.
- Do not connect motor power directly to any ESP32 pin.
Add battery monitoring
Connect a voltage divider (two equal resistors) from the battery to GPIO36 to read battery voltage. The divider halves the voltage to stay within the ESP32 ADC range.
- Use 100 kΩ resistors to minimize current draw from the battery.
- GPIO36 is input-only — do not attempt to use it as output.
Upload and tune PID
Flash the sketch, place the rover upright, and observe the tilt angle on the OLED. Adjust kp, ki, and kd in the code until the rover holds balance for several seconds.
- Start with kp only (set ki and kd to 0), then add kd, and finally a small ki.
Pin assignments
| Pin | Connection | Type |
|---|---|---|
| 3V3 | mpu6050-1 VCC | POWER |
| GND | mpu6050-1 GND | GROUND |
| GPIO 21 | mpu6050-1 SDA | I2C |
| GPIO 22 | mpu6050-1 SCL | I2C |
| 3V3 | oled-1 VCC | POWER |
| GND | oled-1 GND | GROUND |
| GPIO 21 | oled-1 SDA | I2C |
| GPIO 22 | oled-1 SCL | I2C |
| VCC | motor-driver-1 12V → External 7-12V battery | POWER |
| GND | motor-driver-1 GND | GROUND |
| GPIO 25 | motor-driver-1 IN1 | PWM |
| GPIO 26 | motor-driver-1 IN2 | PWM |
| GPIO 32 | motor-driver-1 IN3 | PWM |
| GPIO 33 | motor-driver-1 IN4 | PWM |
Code
#include <Wire.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_SSD1306.h>
#define SDA_PIN 21
#define SCL_PIN 22
#define IN1 25
#define IN2 26
#define IN3 32
#define IN4 33
#define BATT_PIN 36
Adafruit_MPU6050 mpu;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
float targetAngle = 0.0f;
float kp = 24.0f, ki = 0.4f, kd = 0.85f;
float lastError = 0.0f;
float integral = 0.0f;
unsigned long lastLoopUs = 0;
void setupMotorPWM() {
ledcAttach(IN1, 5000, 8);
ledcAttach(IN2, 5000, 8);
ledcAttach(IN3, 5000, 8);
ledcAttach(IN4, 5000, 8);
}
void driveMotors(float control) {
int pwm = constrain((int)fabs(control), 0, 255);
bool forward = control > 0;
ledcWrite(IN1, forward ? pwm : 0);
ledcWrite(IN2, forward ? 0 : pwm);
ledcWrite(IN3, forward ? pwm : 0);
ledcWrite(IN4, forward ? 0 : pwm);
}
void setup() {
Serial.begin(115200);
delay(100);
Wire.begin(SDA_PIN, SCL_PIN);
if (!mpu.begin()) Serial.println("MPU6050 not found");
mpu.setAccelerometerRange(MPU6050_RANGE_4_G);
mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
setupMotorPWM();
pinMode(BATT_PIN, INPUT);
lastLoopUs = micros();
Serial.println("Rover ready");
}
void loop() {
unsigned long nowUs = micros();
float dt = (nowUs - lastLoopUs) / 1000000.0f;
lastLoopUs = nowUs;
sensors_event_t a, g, t;
mpu.getEvent(&a, &g, &t);
float angle = atan2(a.acceleration.x, a.acceleration.z) * 57.2958f;
float error = targetAngle - angle;
integral += error * dt;
integral = constrain(integral, -50.0f, 50.0f);
float derivative = (error - lastError) / dt;
float control = kp * error + ki * integral + kd * derivative;
lastError = error;
driveMotors(control);
float battV = analogRead(BATT_PIN) / 4095.0f * 3.3f * 2.0f;
display.clearDisplay();
display.setCursor(0, 0);
display.printf("Angle: %6.1f deg", angle);
display.setCursor(0, 12);
display.printf("Ctrl: %6.1f", control);
display.setCursor(0, 24);
display.printf("Batt: %4.2f V", battV);
display.setCursor(0, 40);
display.printf("P:%5.1f I:%4.1f D:%4.2f", kp, ki, kd);
display.display();
delay(10);
}
// Run this and build other cool things at schematik.ioReady to build this?
Open this project in Schematik to get the full wiring diagram, pin assignments, and deployable code for the Self-Balancing Rover.
Open in Schematik →