If you are searching for interesting robotics projects that genuinely test your embedded firmware and hardware integration skills, a two-wheeled self-balancing rover is the ultimate benchmark. Unlike simple line-followers, a balancing bot requires high-frequency sensor polling, precise motor control, and rigorous power management. A single millisecond of I2C bus latency or a 200mV voltage sag will send your robot crashing to the workbench.

This guide walks through building a robust self-balancing rover using the ESP32-S3, a 9-DOF ICM-20948 IMU, and a TB6612FNG MOSFET motor driver. We will cover the exact hardware BOM, power delivery rules to prevent brownouts, compilable PD control firmware, and the specific error strings you will encounter when things go wrong.

Project Overview & Difficulty Rating

Difficulty Rating: Advanced (4/5)
Estimated Build Time: 6-8 hours (Hardware: 3h, Firmware Tuning: 3-5h)
Estimated Cost: $45 - $65 USD
Target Board Variant: ESP32-S3-DevKitC-1 (N16R8 variant with 16MB Flash / 8MB PSRAM)

The ESP32-S3 is chosen over the classic ESP32 because its dual-core 240 MHz Xtensa LX7 architecture handles floating-point PID math and I2C transactions without the Wi-Fi stack starving the control loop. The N16R8 variant provides ample memory if you later decide to integrate ROS 2 micro-ROS over Wi-Fi.

Hardware BOM & Pin Mapping

Before ordering parts, note that the TB6612FNG is vastly superior to the L298N H-bridge for this application. The L298N uses bipolar junction transistors (BJTs) with a massive 2V to 3V voltage drop and slow switching times. The TB6612FNG uses MOSFETs, dropping only ~0.5V and allowing the high-frequency PWM required for smooth low-speed balancing.

Component Exact Part / Variant Specs & Notes Est. Price
Microcontroller ESP32-S3-DevKitC-1 (N16R8) Dual-core 240MHz, native USB, 520KB SRAM $14.00
IMU Sensor Adafruit ICM-20948 (PID 4554) 9-DOF, I2C/SPI, low noise density (70 µg/√Hz) $19.95
Motor Driver TB6612FNG Breakout (SparkFun ROB-14451) 1.2A continuous per channel, 100kHz PWM max $9.95
Motors & Wheels N20 JGA25-370 (100 RPM, 6V) + 42mm wheels Metal gear, 6V nominal, requires 2x $12.00
Power Supply 2S LiPo (7.4V, 1000mAh) + LM2596 Buck Buck set to 5.1V for ESP32 VIN pin $15.00

ESP32-S3 Pin Mapping Table

Wire the ICM-20948 to the default I2C bus, and map the TB6612FNG logic pins to the ESP32-S3's LEDC PWM-capable GPIOs. Do not use GPIOs 35-42 (they are input-only on some S3 variants) or GPIO 0/3 (strapping pins).

ESP32-S3 GPIO Target Module Module Pin Function
GPIO 8ICM-20948SCLI2C Clock (400kHz)
GPIO 18ICM-20948SDAI2C Data
GPIO 4TB6612FNGPWMALeft Motor Speed (LEDC CH0)
GPIO 5TB6612FNGAIN1 / AIN2Left Motor Direction (Tie together via H-bridge logic or use 2 pins. Code uses 2 pins: 5 & 6)
GPIO 7TB6612FNGPWMBRight Motor Speed (LEDC CH1)
GPIO 15TB6612FNGSTBYStandby (Active HIGH)

Assembly & Power Delivery Rules

Power delivery is where 90% of balancing bot builds fail. The ESP32-S3 can draw peak currents of 350mA during Wi-Fi transmission, while the motors can pull 500mA each under stall conditions. If you power the ESP32 directly from the TB6612FNG's 5V logic rail, motor startup sag will trigger the ESP32's internal brownout detector, resetting the chip mid-balance.

  1. Isolate the Logic and Motor Rails: Connect your 2S LiPo (7.4V nominal, 8.4V fully charged) directly to the TB6612FNG VMOT pin. This provides raw voltage to the motors.
  2. Step Down for the MCU: Wire the LiPo to an LM2596 buck converter. Adjust the potentiometer on the buck converter to output exactly 5.1V. Connect this 5.1V to the ESP32-S3 DevKit's 5V (or VIN) pin. Do not use the 3V3 pin for main power; the onboard AMS1117 LDO cannot dissipate the heat from a 7.4V to 3.3V drop at 300mA.
  3. Establish a Star Ground: Run the LiPo ground, the buck converter ground, the TB6612FNG GND, and the ESP32 GND to a single common terminal block. A daisy-chained ground will introduce millivolt-level noise into the ICM-20948 analog readings, causing phantom tilt drift.
  4. IMU Placement: Mount the ICM-20948 exactly on the robot's center of gravity (CG) axis. Use double-sided foam tape to dampen high-frequency motor vibrations. Hard-mounting the IMU with metal standoffs will inject 100Hz+ motor ripple into the accelerometer data, destroying your derivative (D) term calculations.

Firmware: PD Control & I2C Error Handling

The following firmware targets the ESP32-S3-DevKitC-1 using the Arduino IDE (ESP32 Core v3.x). We use a Proportional-Derivative (PD) controller rather than full PID. The Integral (I) term is generally omitted in basic balancing bots without wheel encoders, as it causes 'integral windup' when the robot physically cannot move fast enough to correct a large angle error, resulting in violent oscillations.

Install the Adafruit ICM20948 and Adafruit Unified Sensor libraries via the Library Manager before compiling.

#include <Wire.h>
#include <Adafruit_ICM20948.h>

// --- PIN DEFINITIONS ---
#define I2C_SDA 18
#define I2C_SCL 8

#define MOTOR_A_PWM 4
#define MOTOR_A_IN1 5
#define MOTOR_A_IN2 6
#define MOTOR_B_PWM 7
#define MOTOR_B_IN1 16
#define MOTOR_B_IN2 17
#define MOTOR_STBY 15

// --- PID TUNING CONSTANTS ---
// Start with these and tune P first, then D. 
float Kp = 25.0; 
float Kd = 1.2;  
float targetAngle = -1.5; // Offset for physical CG mismatch (degrees)

// --- CONTROL LOOP TIMING ---
unsigned long lastTime = 0;
const float loopTimeSec = 0.01; // 10ms loop (100Hz)

Adafruit_ICM20948 icm;

void setup() {
  Serial.begin(115200);
  delay(1000); // Allow serial monitor to connect

  // Initialize I2C with explicit pins and 400kHz Fast Mode
  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(400000);

  // Sensor Initialization with Error Handling
  if (!icm.begin_I2C()) {
    Serial.println("Error: Failed to find ICM20948 chip!");
    // Blink onboard LED or halt safely
    while (1) { delay(1000); } 
  }
  
  // Configure sensor ranges for balancing (high accel range, high gyro range)
  icm.setAccelRange(ICM20948_ACCEL_RANGE_8_G);
  icm.setGyroRange(ICM20948_GYRO_RANGE_2000_DPS);
  icm.setAccelRateDivisor(4); // ~225Hz output rate
  icm.setGyroRateDivisor(3);  // ~225Hz output rate

  // Initialize Motor Pins
  pinMode(MOTOR_STBY, OUTPUT);
  digitalWrite(MOTOR_STBY, HIGH); // Wake up TB6612FNG
  
  pinMode(MOTOR_A_IN1, OUTPUT); pinMode(MOTOR_A_IN2, OUTPUT);
  pinMode(MOTOR_B_IN1, OUTPUT); pinMode(MOTOR_B_IN2, OUTPUT);

  // Configure ESP32 LEDC PWM for motors (15kHz frequency, 10-bit resolution)
  ledcSetup(0, 15000, 10); ledcAttachPin(MOTOR_A_PWM, 0);
  ledcSetup(1, 15000, 10); ledcAttachPin(MOTOR_B_PWM, 1);

  lastTime = millis();
}

void loop() {
  unsigned long currentTime = millis();
  float dt = (currentTime - lastTime) / 1000.0;

  // Enforce strict 10ms loop timing
  if (dt >= loopTimeSec) {
    lastTime = currentTime;

    sensors_event_t accel, gyro, temp;
    icm.getEvent(&accel, &gyro, &temp);

    // Calculate pitch (assuming X-axis is forward tilt)
    // atan2 returns radians, convert to degrees
    float pitch = atan2(accel.acceleration.x, 
                        sqrt(accel.acceleration.y * accel.acceleration.y + 
                             accel.acceleration.z * accel.acceleration.z)) * 180.0 / PI;

    float error = pitch - targetAngle;
    
    // Gyro X is the rate of change of pitch (deg/s)
    float derivative = gyro.gyro.x * 180.0 / PI; 

    // PD Control Equation
    float output = (Kp * error) + (Kd * derivative);

    // Apply output to motors
    driveMotors(output);
  }
}

void driveMotors(float speed) {
  // Constrain to 10-bit PWM max (1023)
  int pwmVal = constrain(abs(speed), 0, 1023);
  
  if (speed > 0) { // Forward
    digitalWrite(MOTOR_A_IN1, HIGH); digitalWrite(MOTOR_A_IN2, LOW);
    digitalWrite(MOTOR_B_IN1, HIGH); digitalWrite(MOTOR_B_IN2, LOW);
  } else { // Backward
    digitalWrite(MOTOR_A_IN1, LOW); digitalWrite(MOTOR_A_IN2, HIGH);
    digitalWrite(MOTOR_B_IN1, LOW); digitalWrite(MOTOR_B_IN2, HIGH);
  }
  
  ledcWrite(0, pwmVal);
  ledcWrite(1, pwmVal);
}

Debugging: When the Rover Fails to Balance

Embedded robotics is mostly debugging. When your rover inevitably fails, check these first three things before altering the PID code:

  1. Verify the IMU Orientation: If the robot accelerates backward when tilted forward, your pitch calculation sign is inverted, or your motor direction pins are swapped. Hold the bot upright and tilt it forward; the wheels must roll forward to catch the fall.
  2. Check the Loop Timing (dt): If your derivative (D) term is wildly erratic, print your dt variable to the serial monitor. If it fluctuates between 8ms and 25ms, Wi-Fi or serial printing is blocking your loop. Disable Wi-Fi and limit Serial prints to once every 10 loops.
  3. Inspect the Mechanical Bind: Lift the wheels off the desk. Apply a 30% PWM duty cycle. Both wheels should spin freely and at the same speed. If one stutters, you have a physical bind or a cold solder joint on the TB6612FNG header.

Exact Error Strings & Ranked Causes

If the ESP32 crashes or fails to initialize, look for these exact strings in the Serial Monitor:

Error String: Error: Failed to find ICM20948 chip!
Ranked Causes:
1. Missing I2C pull-up resistors. The Adafruit breakout has them, but if you are using a raw module, add 4.7kΩ resistors to SDA and SCL.
2. I2C address mismatch. The ICM-20948 defaults to 0x69. If the AD0 pin is pulled high, it shifts to 0x68. Pass the correct address to begin_I2C(0x68).
3. SDA/SCL swapped. Verify GPIO 18 is SDA and GPIO 8 is SCL.
Error String: Brownout detector was triggered
Ranked Causes:
1. Power supply sag. The LM2596 buck converter is not rated for the transient current spike when both motors start simultaneously. Add a 1000µF electrolytic capacitor across the 5V and GND pins on the ESP32 DevKit.
2. USB cable voltage drop. If testing via USB, the PC port cannot supply the 500mA+ required. Use a powered USB hub or a dedicated 5V 2A wall adapter.
Error String: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)
Ranked Causes:
1. I2C Bus Lockup. The ICM-20948 stopped responding, causing the Wire library to hang indefinitely, tripping the ESP32's hardware watchdog timer. Implement a timeout wrapper around I2C reads, or use the ESP32's Wire.setTimeOut(50) function in setup to fail gracefully after 50ms.

Extending and Simplifying the Build

Depending on your budget, timeline, and end-goal for your robotics portfolio, you can scale this project up or down.

Modification Hardware Changes Firmware Impact Use Case
Simplify Swap ICM-20948 for MPU6050 ($4). Swap TB6612FNG for L298N ($5). Use standard DMP library for MPU6050. Expect heavier filtering needed due to L298N deadzone. High school physics demos, tight budgets (<$30 total).
Extend (Encoders) Add N20 magnetic encoders (12 PPR) to motor shafts. Wire to ESP32 PCNT (Pulse Counter) peripherals. Enables full PID (adds Integral term) and velocity control loops. Eliminates steady-state drift. University capstone projects, autonomous navigation bases.
Extend (ROS 2) Keep ESP32-S3. Add a Raspberry Pi 5 running Ubuntu and ROS 2 Jazzy. Connect via UART. Flash micro-ROS agent on ESP32. Offload SLAM and path planning to the Pi. Professional R&D, indoor mapping, multi-agent swarm research.

For further reading on the sensor hardware, refer to the Adafruit ICM-20948 wiring and calibration guide. If you are pushing the ESP32-S3 to its limits with motor control and Wi-Fi simultaneously, review the official Espressif ESP32-S3 Datasheet, specifically the strapping pin configurations and current consumption graphs in Section 3.2.