Community project

ESP32 Self-Balancing Hoverboard

ESP32
Photo of ESP32 Self-Balancing Hoverboard
Generated with AI

vivobook16x85

Published September 1, 2026

This project builds a self-balancing platform using an ESP32 microcontroller, dual N20 micro motors, and an MPU-6050 inertial measurement unit. The system continuously reads pitch angle from the IMU and applies corrective motor commands through a DRV8833 driver to maintain balance, with a safety-focused design that includes an emergency stop switch, battery protection board, and restrained test frame for safe development.

The guide provides a complete wiring diagram, parts list with battery and power management components, and step-by-step assembly instructions for building the mechanical frame and electrical connections. Firmware is included with a PID control loop running at 200 Hz, complementary filtering for angle estimation, and configurable tuning parameters—all designed for iterative bench testing before deployment.

Wiring diagram

Wiring diagram for ESP32 Self-Balancing Hoverboard

Gather all the parts

QtyComponent
1

DFRobot SEN0142 Fermion MPU-6050 6 DOF Sensor Breakout

MPU-6050

DFRobot SEN0142 MPU-6050 breakout with 3-5 V board input, I2C interface, onboard I2C pull-ups, and i2cdevlib Arduino example coverage.

1

DRV8833 Dual DC Motor Driver

DRV8833 carrier

Dual H-bridge DC/stepper motor driver IC or carrier module. VM motor supply range is 2.7V-10.8V; logic inputs support 1.8V, 3.3V, and 5V controllers. TI rates the PWP/RTY package options at 1.5A RMS and 2A peak per H-bridge at VM=5V and 25C; carrier-board current depends on board layout and cooling. Controlled with AIN1/AIN2 and BIN1/BIN2 inputs, which may be driven as digital or PWM signals. nSLEEP must be high for the bare IC, while some carrier boards pull it high by default. nFAULT is an open-drain fault output.

1

N20-6V-300RPM Micro Metal Gear Motor

6 V, 300 RPM

N20 form-factor brushed DC micro metal gear motor rated 6V with about 300 RPM no-load speed. Two bare motor terminals connect directly to a motor driver output channel such as DRV8833 AOUT1/AOUT2 or BOUT1/BOUT2. Direction and speed are controlled by the motor driver. No firmware library is required for the motor itself.

1

N20-6V-300RPM Micro Metal Gear Motor

6 V, 300 RPM

N20 form-factor brushed DC micro metal gear motor rated 6V with about 300 RPM no-load speed. Two bare motor terminals connect directly to a motor driver output channel such as DRV8833 AOUT1/AOUT2 or BOUT1/BOUT2. Direction and speed are controlled by the motor driver. No firmware library is required for the motor itself.

1

18650 Holder

2 cells in series

Generic holder for one or two removable 18650 Li-ion cells. It is a mechanical/electrical power holder, not a charger or protection circuit; pair with a charger/BMS and regulator appropriate to the cell count.

1

MP1584 Buck Converter

Set to 5.0 V

Adjustable buck (step-down) DC-DC converter, 4.5-28 V in -> 0.8-20 V out, ~3 A. Configured to 5 V to step a 9 V / 12 V supply or battery pack down to the board's 5 V rail.

1

2S Li-ion BMS protection board

2S, 8.4 V Li-ion protection

A protection board that disconnects a two-cell Li-ion pack if a cell is overcharged, over-discharged, or overloaded.

1

Latching emergency-stop switch, normally closed

NC, latching

A red push-button switch that opens the battery feed when pressed and stays off until reset.

1

10 kΩ resistor

10 kΩ

A small resistor that holds the driver fault signal high until the driver reports a fault.

1

24v Buck Converter

Set to 6.0 V

LM2596-based adjustable step-down buck converter module. Commonly used to regulate a higher battery rail, such as a 2S 18650 pack, down to 5V for Arduino logic. It is a regulator, not a charger or battery protection board.

Assemble it in 6 steps

1. Build and restrain the test frame

Make a rigid deck about 35 cm wide. Fix one geared motor and wheel at each end so both wheel axles are level and parallel. Clamp the deck in a test stand or hang it from a strong strap so the wheels can turn without carrying a person.

  • Start with the wheels off the floor; this lets you confirm direction without the rig escaping.
  • Do not stand on, ride, or hold this prototype between your feet. A software mistake can make the wheels spin suddenly.

2. Wire the motors to the driver

Connect the left motor’s two terminals to AOUT1 and AOUT2 on motor_driver_1, and connect the right motor’s two terminals to BOUT1 and BOUT2. If a wheel later corrects in the wrong direction, unplug the battery and swap that motor’s two wires.

  • Use thicker, short wires for the motors because motor current is much larger than the sensor current.
  • Never move motor wires while the battery is connected; a loose wire can short the battery or damage the driver.

3. Connect the battery protection and stop switch

Put two matched protected 18650 cells into battery_holder in the direction marked on its plastic case. Connect BAT+ to bms_2s B+ and BAT- to B-. Connect BMS P+ to estop_1 COM. Join BMS P- to the shared GND rail. Connect estop_1 NC to both buck-converter inputs; pressing the red button must remove power from both converters.

  • Set buck_5v to exactly 5.0 V and motor_buck_6v to exactly 6.0 V with a multimeter before connecting either output to the board or driver.
  • A 2-cell Li-ion pack can deliver enough current to melt thin wires. Fit the cells last, use an appropriate 2S BMS, and make sure no bare positive wire can touch ground.
  • Do not charge the two cells through this project as drawn; use a proper matched-cell 2S Li-ion charger connected according to the BMS manufacturer’s instructions.

4. Power the controller, sensor, and motor driver

Connect buck_5v VOUT to the ESP32 VIN or 5V pin and connect its GND to ESP32 GND. Connect motor_buck_6v VOUT+ to motor_driver_1 VM and VOUT- to GND. Connect motor_driver_1 GND to the same GND rail. Connect imu_1 VIN to ESP32 3V3 and imu_1 GND to GND.

  • Every part needs the same ground wire; without it, the control signals have no shared reference.
  • Do not connect the 6.0 V motor supply to the ESP32, MPU-6050, or their 3.3 V pins; that can damage them.

5. Connect the small signal wires

Connect imu_1 SDA to ESP32 GPIO21 and imu_1 SCL to GPIO22. Connect motor_driver_1 AIN1 to GPIO25, AIN2 to GPIO26, BIN1 to GPIO27, BIN2 to GPIO32, and nSLEEP to GPIO33. Connect motor_driver_1 nFAULT to GPIO14. Connect fault_pullup between that same nFAULT wire and ESP32 3V3: resistor A joins nFAULT and resistor B goes to 3V3.

  • Keep the MPU-6050 firmly fixed at the centre of the deck, with its printed axes aligned consistently with the deck; loose mounting makes the angle reading unreliable.
  • Make sure VCC and GND are not swapped on the sensor — swapped power can damage it. Keep motor wires away from the sensor wires to reduce electrical noise.

6. Perform the first restrained test

Leave the wheels raised or the frame firmly clamped. Release the emergency-stop button, power the rig, and hold it upright and still for the short calibration period. Then gently tip it a few degrees while ready to press the stop button. If either wheel drives farther into the fall, press stop, remove the battery, and swap that motor’s two wires.

  • The initial motor command is limited to reduce test risk. The code turns both motors off if the tilt passes about 35 degrees or the IMU/driver reports a fault.
  • Keep hands, hair, and loose clothing away from the spinning wheels. Press the emergency stop immediately if the rig moves unexpectedly.

Review all connections

1. Connections between "battery_holder" and "ESP32"

Functionbattery_holderESP32
powerBAT+2S Li-ion BMS protection board B+EXT
groundBAT-2S Li-ion BMS protection board B-EXT

2. Connections between "bms_2s" and "ESP32"

Functionbms_2sESP32
powerP+Latching emergency-stop switch, normally closed COMEXT
groundP-GND

3. Connections between "estop_1" and "ESP32"

Functionestop_1ESP32
powerNCMP1584 Buck Converter VINEXT
powerNC24v Buck Converter VIN+EXT

4. Connections between "buck_5v" and "ESP32"

Functionbuck_5vESP32
groundGNDGND
powerVOUTVIN

5. Connections between "motor_buck_6v" and "ESP32"

Functionmotor_buck_6vESP32
groundVIN-GND
groundVOUT-GND
powerVOUT+DRV8833 Dual DC Motor Driver VMEXT

6. Connections between "imu_1" and "ESP32"

Functionimu_1ESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

7. Connections between "motor_driver_1" and "ESP32"

Functionmotor_driver_1ESP32
groundGNDGND
pwmAIN1GPIO 25
pwmAIN2GPIO 26
pwmBIN1GPIO 27
pwmBIN2GPIO 32
digitalnSLEEPGPIO 33
dataAOUT1N20-6V-300RPM Micro Metal Gear Motor M+EXT
dataAOUT2N20-6V-300RPM Micro Metal Gear Motor M-EXT
dataBOUT1N20-6V-300RPM Micro Metal Gear Motor M+EXT
dataBOUT2N20-6V-300RPM Micro Metal Gear Motor M-EXT
digitalnFAULTGPIO 14

8. Connections between "fault_pullup" and "ESP32"

Functionfault_pullupESP32
powerB3V3
digitalADRV8833 Dual DC Motor Driver nFAULTEXT

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <math.h>


// Forward declarations
bool writeRegister(uint8_t reg, uint8_t value);
bool readMotion(int16_t &ax, int16_t &ay, int16_t &az, int16_t &gx, int16_t &gy, int16_t &gz);
void stopMotors();
void setOneMotor(int in1, int in2, int command);
void disableForFault(const char *reason);
bool calibrateImu();

constexpr uint8_t MPU_ADDR = 0x68;
constexpr int MOTOR_A_IN1 = 25;
constexpr int MOTOR_A_IN2 = 26;
constexpr int MOTOR_B_IN1 = 27;
constexpr int MOTOR_B_IN2 = 32;
constexpr int DRIVER_SLEEP_PIN = 33;
constexpr int DRIVER_FAULT_PIN = 14;
constexpr int IMU_SDA_PIN = 21;
constexpr int IMU_SCL_PIN = 22;

constexpr float LOOP_SECONDS = 0.005f;       // 200 Hz
constexpr float COMPLEMENTARY_ALPHA = 0.985f;
constexpr float TILT_CUTOFF_DEG = 35.0f;
constexpr int MAX_PWM = 130;                 // deliberately limited for bench testing
constexpr float KP = 8.0f;
constexpr float KI = 0.15f;
constexpr float KD = 0.22f;

float pitchDeg = 0.0f;
float gyroBiasY = 0.0f;
float integral = 0.0f;
float previousError = 0.0f;
bool armed = false;
unsigned long lastLoopUs = 0;

bool writeRegister(uint8_t reg, uint8_t value) {
  Wire.beginTransmission(MPU_ADDR);
  Wire.write(reg);
  Wire.write(value);
  return Wire.endTransmission() == 0;
}

bool readMotion(int16_t &ax, int16_t &ay, int16_t &az, int16_t &gx, int16_t &gy, int16_t &gz) {
  Wire.beginTransmission(MPU_ADDR);
  Wire.write(0x3B);
  if (Wire.endTransmission(false) != 0 || Wire.requestFrom(MPU_ADDR, (uint8_t)14) != 14) return false;
  ax = (Wire.read() << 8) | Wire.read();
  ay = (Wire.read() << 8) | Wire.read();
  az = (Wire.read() << 8) | Wire.read();
  gx = (Wire.read() << 8) | Wire.read();
  gy = (Wire.read() << 8) | Wire.read();
  gz = (Wire.read() << 8) | Wire.read();
  return true;
}

void stopMotors() {
  analogWrite(MOTOR_A_IN1, 0);
  analogWrite(MOTOR_A_IN2, 0);
  analogWrite(MOTOR_B_IN1, 0);
  analogWrite(MOTOR_B_IN2, 0);
}

void setOneMotor(int in1, int in2, int command) {
  command = constrain(command, -MAX_PWM, MAX_PWM);
  if (command > 0) {
    analogWrite(in1, command);
    analogWrite(in2, 0);
  } else if (command < 0) {
    analogWrite(in1, 0);
    analogWrite(in2, -command);
  } else {
    analogWrite(in1, 0);
    analogWrite(in2, 0);
  }
}

void disableForFault(const char *reason) {
  armed = false;
  integral = 0.0f;
  stopMotors();
  digitalWrite(DRIVER_SLEEP_PIN, LOW);
  Serial.println(reason);
}

bool calibrateImu() {
  const int samples = 600;
  float gyroTotal = 0.0f;
  float pitchTotal = 0.0f;
  for (int i = 0; i < samples; ++i) {
    int16_t ax, ay, az, gx, gy, gz;
    if (!readMotion(ax, ay, az, gx, gy, gz)) return false;
    gyroTotal += gy;
    pitchTotal += atan2f((float)ax, sqrtf((float)ay * ay + (float)az * az)) * 180.0f / PI;
    delay(3);
  }
  gyroBiasY = gyroTotal / samples;
  pitchDeg = pitchTotal / samples;
  return true;
}

void setup() {
  Serial.begin(115200);
  pinMode(MOTOR_A_IN1, OUTPUT);
  pinMode(MOTOR_A_IN2, OUTPUT);
  pinMode(MOTOR_B_IN1, OUTPUT);
  pinMode(MOTOR_B_IN2, OUTPUT);
  pinMode(DRIVER_SLEEP_PIN, OUTPUT);
  pinMode(DRIVER_FAULT_PIN, INPUT); // external 10 kOhm pull-up is fitted
  stopMotors();
  digitalWrite(DRIVER_SLEEP_PIN, LOW);

  Wire.begin(IMU_SDA_PIN, IMU_SCL_PIN);
  Wire.setClock(400000);
  if (!writeRegister(0x6B, 0x00) || !writeRegister(0x1B, 0x00) || !writeRegister(0x1C, 0x00)) {
    Serial.println("IMU not detected; motors remain off.");
    return;
  }

  Serial.println("Hold the secured rig upright and still: calibrating IMU.");
  if (!calibrateImu()) {
    Serial.println("IMU read failed; motors remain off.");
    return;
  }
  if (digitalRead(DRIVER_FAULT_PIN) == LOW) {
    Serial.println("Motor-driver fault present; motors remain off.");
    return;
  }
  digitalWrite(DRIVER_SLEEP_PIN, HIGH);
  armed = true;
  lastLoopUs = micros();
  Serial.println("Armed at low power. Keep the rig restrained.");
}

void loop() {
  if (!armed) {
    delay(50);
    return;
  }

  const unsigned long now = micros();
  if ((unsigned long)(now - lastLoopUs) < 5000) return;
  float dt = (now - lastLoopUs) / 1000000.0f;
  lastLoopUs = now;
  if (dt > 0.02f) dt = LOOP_SECONDS;

  if (digitalRead(DRIVER_FAULT_PIN) == LOW) {
    disableForFault("Motor-driver fault: motors disabled.");
    return;
  }

  int16_t ax, ay, az, gx, gy, gz;
  if (!readMotion(ax, ay, az, gx, gy, gz)) {
    disableForFault("IMU communication fault: motors disabled.");
    return;
  }

  const float accelPitch = atan2f((float)ax, sqrtf((float)ay * ay + (float)az * az)) * 180.0f / PI;
  const float gyroRate = ((float)gy - gyroBiasY) / 131.0f;
  pitchDeg = COMPLEMENTARY_ALPHA * (pitchDeg + gyroRate * dt) + (1.0f - COMPLEMENTARY_ALPHA) * accelPitch;

  if (fabsf(pitchDeg) > TILT_CUTOFF_DEG) {
    disableForFault("Tilt cutoff: stand the rig upright, then reset power.");
    return;
  }

  const float error = -pitchDeg; // upright is the calibrated zero-angle target
  integral = constrain(integral + error * dt, -20.0f, 20.0f);
  const float derivative = (error - previousError) / dt;
  previousError = error;
  const int motorCommand = (int)(KP * error + KI * integral + KD * derivative);

  // Same command on both wheels: balance only. No rider/joystick motion control in this bench prototype.
  setOneMotor(MOTOR_A_IN1, MOTOR_A_IN2, motorCommand);
  setOneMotor(MOTOR_B_IN1, MOTOR_B_IN2, motorCommand);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik