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
Decision Path Termination: If your payload is under 500g and you are running a 2S LiPo battery, pick the TB6612FNG. The L298N will drop your 7.4V battery down to 5.4V at the motors, causing brownouts when the PID loop demands high torque to correct a tilt. The TB6612FNG uses MOSFETs, preserving your voltage headroom and supporting the high-frequency PWM needed for smooth low-speed motor control.

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
Power Decoupling is Mandatory: N20 motors generate massive back-EMF spikes. If you do not solder a 100nF ceramic capacitor directly across the terminals of each motor, and a 470µF electrolytic capacitor across the TB6612FNG VMOT and GND pins, the voltage ripple will brownout the ESP32's 3.3V LDO, causing a hard reset every time the PID loop demands a correction.

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.