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 walks you through building a self-balancing two-wheeled rover that stays upright using an ESP32, an MPU6050 inertial measurement unit, a pair of DC gear motors with an L298N driver, and a compact OLED screen for real-time telemetry. The rover continuously reads accelerometer and gyroscope data, fuses them with a complementary filter, and feeds the resulting tilt angle into a PID control loop that adjusts motor speed dozens of times per second. The OLED displays live tilt angle, battery voltage, and PID tuning values so you can observe the control system at work without needing a serial monitor.
PID control is one of the most important concepts in robotics and industrial automation, and a self-balancing robot is the classic hands-on way to learn it. You will implement proportional, integral, and derivative terms from scratch, understand how each one contributes to stability, and develop an intuition for tuning gains by watching the rover respond in real time. The project also covers reading raw IMU registers over I2C, converting them into meaningful orientation data, and managing two independent motor channels through an H-bridge driver -- skills that underpin everything from drones to robotic arms.
When you are finished you will have a rover that balances on two wheels, recovers from gentle pushes, and displays its internal state on screen. The modular code structure makes it straightforward to add remote control over Bluetooth or Wi-Fi, introduce obstacle avoidance with an ultrasonic sensor, or swap in stepper motors for finer control. This is a satisfying intermediate project that bridges the gap between simple sensor demos and full autonomous robotics platforms. The same MPU6050 IMU used here also features in the fitness wristband, where accelerometer data drives step counting instead of balance control.
Wiring diagram
Wiring diagram
Components needed
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 →