Why a Self-Balancing Bot Tops the List of Awesome Robotics Projects
When evaluating awesome robotics projects for embedded development, the self-balancing robot remains the definitive benchmark. Unlike line-followers or obstacle-avoidance rovers that rely on simple threshold logic, a two-wheeled inverted pendulum forces you to master real-time sensor fusion, I2C bus management, and Proportional-Integral-Derivative (PID) control loops. You are fighting gravity at 100Hz.
This guide details how to build a self-balancing bot using the ESP32-WROOM-32 and the ubiquitous MPU6050 IMU. We will skip the abstract theory and go straight to the bench: exact part numbers, a decision matrix for your motor driver, a pin mapping table, and production-grade C++ code with I2C error handling.
Decision Tree: Choosing Your Motor Driver and Controller
The most common point of failure in amateur robotics builds is selecting a motor driver based on price rather than voltage drop and switching speed. Here is the decision path to select the right driver for a 2S LiPo (7.4V nominal) and N20 gear motor setup.
| Criterion | L298N (BJT) | TB6612FNG (MOSFET) | DRV8871 (x2) |
|---|---|---|---|
| Voltage Drop | ~2.0V (Massive heat) | ~0.5V (Efficient) | ~0.6V |
| Max PWM Frequency | < 25 kHz | Up to 100 kHz | Up to 30 kHz |
| Logic Level | 5V (Needs level shifting for ESP32) | 3.3V / 5V tolerant | 3.3V / 5V tolerant |
| Verdict for 2S LiPo | AVOID: Starves motors of voltage | DEFAULT PICK | Overkill / Harder to wire dual |
Hardware Spec Sheet & Exact Parts List
Do not substitute the microcontroller variant without adjusting the code's pin definitions and ADC attenuation settings. This build targets the standard 30-pin DevKit V1.
| Component | Exact Variant / Model | Est. Cost (2026) | Notes |
|---|---|---|---|
| Microcontroller | ESP32-WROOM-32 DevKit V1 (30-pin) | $6.00 | Ensure it's the 30-pin, not the 38-pin ESP32-S variant. |
| IMU Sensor | GY-521 Breakout (MPU6050) | $3.50 | Must include 3.3V LDO and I2C pull-ups. |
| Motor Driver | TB6612FNG Dual Motor Driver | $4.00 | Pololu or generic SparkFun-compatible footprint. |
| Motors | N20 Gear Motor, 6V, 300 RPM | $8.00 (pair) | 300 RPM provides the best torque/speed balance for 48mm wheels. |
| Power Supply | 2S 7.4V 1000mAh LiPo (JST-XH) | $12.00 | Must have a BMS. Never use unprotected 18650s in series. |
| Chassis | Custom 3D Print or Acrylic Kit | $10.00 | Keep the battery at the top to raise the center of gravity slightly. |
Pin Mapping & Wiring Guide
The ESP32 has specific pins that are input-only (GPIO 34-39) and pins that output PWM on boot (GPIO 1, 3, 5). The mapping below avoids these boot-strapping pitfalls.
| ESP32 GPIO | Target Module | Module Pin | Wire Color (Standard) |
|---|---|---|---|
| 21 | MPU6050 | SDA | Green |
| 22 | MPU6050 | SCL | Yellow |
| 32 | TB6612FNG | PWMA | Orange |
| 33 | TB6612FNG | PWMB | Orange |
| 25 | TB6612FNG | AIN1 / AIN2 | Red / Brown |
| 26 | TB6612FNG | BIN1 / BIN2 | Red / Brown |
| 27 | TB6612FNG | STBY | Blue |
| 3V3 | MPU6050 / TB6612 VCC | VCC / VCC | Red |
| GND | All Modules | GND | Black |
Complete ESP32 PID Control Code
This code targets the ESP32-WROOM-32 DevKit V1. It uses a lightweight complementary filter instead of a heavy Kalman filter library to maintain a 100Hz loop rate without RTOS task starvation. It includes raw I2C error handling to prevent bus lockups.
#include <Wire.h>
// --- PIN DEFINITIONS ---
#define PIN_SDA 21
#define PIN_SCL 22
#define PIN_PWMA 32
#define PIN_PWMB 33
#define PIN_AIN1 25
#define PIN_AIN2 26
#define PIN_BIN1 14
#define PIN_BIN2 12
#define PIN_STBY 27
// --- I2C & IMU CONFIG ---
#define MPU_ADDR 0x68
#define I2C_FREQ 400000 // 400kHz Fast Mode
// --- PID TUNING CONSTANTS ---
// Start with these, then tune Kp first, then Kd.
float Kp = 35.0;
float Ki = 0.5;
float Kd = 1.2;
float targetAngle = -1.5; // Offset for physical CG imbalance
// --- SYSTEM VARIABLES ---
float pitch = 0;
float pidOutput = 0;
float integral = 0;
float lastError = 0;
unsigned long lastTime = 0;
// --- I2C ERROR HANDLING HELPER ---
uint8_t i2cReadReg(uint8_t reg, uint8_t count, uint8_t* data) {
Wire.beginTransmission(MPU_ADDR);
Wire.write(reg);
uint8_t err = Wire.endTransmission(false);
if (err != 0) return err; // 1:Data too long, 2:NACK, 3:Other, 4:Timeout
Wire.requestFrom((uint8_t)MPU_ADDR, count);
for (uint8_t i = 0; i < count; i++) {
if (Wire.available()) {
data[i] = Wire.read();
} else {
return 5; // Buffer underflow
}
}
return 0; // Success
}
void setup() {
Serial.begin(115200);
// Initialize Motor Pins
pinMode(PIN_PWMA, OUTPUT); pinMode(PIN_PWMB, OUTPUT);
pinMode(PIN_AIN1, OUTPUT); pinMode(PIN_AIN2, OUTPUT);
pinMode(PIN_BIN1, OUTPUT); pinMode(PIN_BIN2, OUTPUT);
pinMode(PIN_STBY, OUTPUT);
digitalWrite(PIN_STBY, LOW); // Keep motors disabled during setup
// Configure ESP32 LEDC PWM (Channels 0 & 1, 20kHz, 8-bit resolution)
ledcSetup(0, 20000, 8);
ledcSetup(1, 20000, 8);
ledcAttachPin(PIN_PWMA, 0);
ledcAttachPin(PIN_PWMB, 1);
// Initialize I2C
Wire.begin(PIN_SDA, PIN_SCL, I2C_FREQ);
// Wake up MPU6050
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x6B); // PWR_MGMT_1 register
Wire.write(0); // Clear sleep bit
if (Wire.endTransmission() != 0) {
Serial.println("FATAL: MPU6050 not found on I2C bus. Check wiring.");
while(1) { delay(1000); }
}
// Set DLPF (Digital Low Pass Filter) to ~44Hz bandwidth
Wire.beginTransmission(MPU_ADDR);
Wire.write(0x1A); Wire.write(0x03);
Wire.endTransmission();
digitalWrite(PIN_STBY, HIGH); // Enable motors
lastTime = micros();
}
void loop() {
unsigned long now = micros();
float dt = (now - lastTime) / 1000000.0;
// Enforce ~100Hz loop rate
if (dt < 0.01) return;
lastTime = now;
// Read Accelerometer (0x3B) and Gyroscope (0x43)
uint8_t imuData[14];
uint8_t i2cErr = i2cReadReg(0x3B, 14, imuData);
if (i2cErr != 0) {
Serial.printf("I2C Read Error: %d. Resetting bus.\n", i2cErr);
Wire.end();
Wire.begin(PIN_SDA, PIN_SCL, I2C_FREQ);
return; // Skip this loop iteration
}
// Parse raw data (Big Endian)
int16_t accX = (imuData[0] << 8) | imuData[1];
int16_t accY = (imuData[2] << 8) | imuData[3];
int16_t accZ = (imuData[4] << 8) | imuData[5];
int16_t gyroY = (imuData[12] << 8) | imuData[13];
// Calculate angles
float accAngle = atan2(accX, sqrt(accY*accY + accZ*accZ)) * 180.0 / PI;
float gyroRate = gyroY / 131.0; // +/- 250 deg/s sensitivity
// Complementary Filter (98% Gyro, 2% Accel)
pitch = 0.98 * (pitch + gyroRate * dt) + 0.02 * accAngle;
// PID Calculation
float error = pitch - targetAngle;
integral += error * dt;
integral = constrain(integral, -200, 200); // Anti-windup
float derivative = (error - lastError) / dt;
lastError = error;
pidOutput = (Kp * error) + (Ki * integral) + (Kd * derivative);
// Motor Control Logic
applyMotorPower(pidOutput);
}
void applyMotorPower(float power) {
// Deadband to prevent jitter at standstill
if (abs(power) < 15) {
ledcWrite(0, 0); ledcWrite(1, 0);
return;
}
int pwmVal = constrain(abs(power), 0, 255);
if (power > 0) { // Move Forward
digitalWrite(PIN_AIN1, HIGH); digitalWrite(PIN_AIN2, LOW);
digitalWrite(PIN_BIN1, HIGH); digitalWrite(PIN_BIN2, LOW);
} else { // Move Backward
digitalWrite(PIN_AIN1, LOW); digitalWrite(PIN_AIN2, HIGH);
digitalWrite(PIN_BIN1, LOW); digitalWrite(PIN_BIN2, HIGH);
}
ledcWrite(0, pwmVal);
ledcWrite(1, pwmVal);
}
Debugging: First Three Things to Check When It Fails
When your bot immediately falls over or the ESP32 reboots, do not start randomly changing PID values. Follow this ranked diagnostic path.
1. The I2C Bus Lockup (NACK Error)
Symptom: The serial monitor prints I2C Read Error: 2. Resetting bus. and the motors stop.
Root Cause: Error code 2 means the MPU6050 sent a NACK (Not Acknowledged). This happens when motor EMI couples into the SDA/SCL lines, corrupting the I2C state machine, or if your I2C bus capacitance exceeds 400pF due to long, unshielded jumper wires.
Fix: Keep SDA/SCL wires under 10cm. Add 4.7kΩ pull-up resistors to 3.3V on both lines if your GY-521 breakout lacks them. The code above includes an automatic Wire.end() and Wire.begin() reset to recover from this without a full CPU reboot.
2. The Watchdog Timer Panic
Symptom: The ESP32 hard-reboots every few seconds with the exact error string: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1).
Root Cause: The ESP32's RTOS requires the loop to yield to background tasks (like WiFi/BT stacks). If your I2C read hangs due to clock stretching, or if you put a delay() inside the PID loop, the hardware watchdog triggers.
Fix: Never use delay() in a balancing bot. Use the micros() non-blocking timer implemented in the code above. If the error persists, add yield(); at the very end of the loop() function.
3. High-Frequency Motor Jitter at Standstill
Symptom: The bot balances, but the wheels vibrate violently back and forth, draining the battery and overheating the TB6612FNG.
Root Cause: 'Derivative Kick'. The Kd term amplifies high-frequency noise from the MPU6050's accelerometer. Alternatively, the physical chassis is vibrating, feeding mechanical noise back into the IMU.
Fix: First, ensure your IMU is mounted on double-sided foam tape, not hard-screwed to the chassis. Second, lower your Kd value by 50%. Third, verify the deadband logic in the applyMotorPower() function is active (set to < 15 PWM).
How to Extend or Simplify the Build
Depending on your end goal, you can scale this project up or down.
To Simplify (The Pure Control-Theory Route)
If you do not need Bluetooth telemetry or WiFi OTA updates, swap the ESP32 for an Arduino Nano V3 (ATmega328P).
Why? The ATmega328P lacks an RTOS, meaning there is zero background task jitter. Your PID loop timing will be incredibly deterministic without needing yield() calls. You will need to change the PWM implementation to use analogWrite() and adjust the pin definitions to match the Nano's D-pins (e.g., D9, D10 for PWM).
To Extend (The Connected Robotics Route)
To turn this into a telepresence or data-logging platform, leverage the ESP32's dual-core architecture and wireless radios:
- Live PID Tuning: Implement ESP-NOW to send a secondary ESP32's joystick inputs to the bot with <5ms latency, bypassing WiFi router overhead.
- Kalman Filtering: Replace the complementary filter with a Kalman filter library (like
SimpleKalmanFilter) to eliminate accelerometer noise during high-G maneuvers, though this will consume roughly 15% more CPU cycles per loop. - Telemetry Dashboard: Spin up a FreeRTOS task on Core 0 to push pitch, error, and PID output data via WebSockets to a browser-based dashboard, leaving Core 1 exclusively dedicated to the 100Hz I2C and motor control loop.
Building a self-balancing robot bridges the gap between blinking an LED and designing industrial control systems. By selecting the TB6612FNG, decoupling your power rails, and implementing non-blocking I2C reads, you eliminate the 90% of hardware failures that plague most embedded robotics projects.






