Community project

ESP32 Quadcopter Controller

ESP32
Photo of ESP32 Quadcopter Controller
Generated with AI

Adriel Balarezo Bravo

Published October 14, 2026

This guide builds a tethered ESP32-based quadcopter controller suitable for initial flight testing and development. The system uses an MPU-6050 inertial measurement unit for attitude sensing, four brushless motors with electronic speed controllers, and a 3S LiPo battery stepped down to logic levels via a buck converter. All components are wired to the ESP32, which runs a stabilization loop and exposes a web interface for arming and throttle control.

Following this guide, you will receive a complete wiring diagram, a detailed parts list with recommended suppliers, the full Arduino firmware with WiFi setup and PID stabilization, and step-by-step assembly instructions. The firmware includes safety features like command timeouts and deliberately limited motor output for safe tethered operation. Before first flight, the guide walks through motor direction verification without propellers attached.

Wiring diagram

Wiring diagram for ESP32 Quadcopter Controller

Gather all the parts

QtyComponent
1

DFRobot SEN0142 Fermion MPU-6050 6 DOF Sensor Breakout

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

1

LiPo 3S 2200–3000 mAh battery pack

The rechargeable three-cell battery pack that supplies the high current for the four motor controllers.

1

MP1584 Buck Converter

Set output to 5.0 V before connection

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

30 A 3S brushless ESC — front left

The speed controller that turns the battery power into three-phase power for the front-left brushless motor.

1

30 A 3S brushless ESC — front right

The speed controller that turns the battery power into three-phase power for the front-right brushless motor.

1

30 A 3S brushless ESC — rear right

The speed controller that turns the battery power into three-phase power for the rear-right brushless motor.

1

30 A 3S brushless ESC — rear left

The speed controller that turns the battery power into three-phase power for the rear-left brushless motor.

1

2200 KV brushless motor — front left

The front-left three-wire brushless motor that creates lift when a propeller is fitted.

1

2200 KV brushless motor — front right

The front-right three-wire brushless motor that creates lift when a propeller is fitted.

1

2200 KV brushless motor — rear right

The rear-right three-wire brushless motor that creates lift when a propeller is fitted.

1

2200 KV brushless motor — rear left

The rear-left three-wire brushless motor that creates lift when a propeller is fitted.

Assemble it in 6 steps

1. Prepare the frame and remove the propellers

Bolt the four 2200 KV motors to the four arms, naming them front-left, front-right, rear-right, and rear-left when viewed from above. Leave every propeller off for all first power-up and direction tests.

  • Mark the front of the frame with tape so the motor names do not get swapped.
  • A bare 2200 KV motor can start suddenly; fitting propellers during setup can cause serious cuts or pull the frame from the bench.

2. Join each motor to its own controller

Connect the three wires from motor_fl to M1, M2, and M3 on esc_fl. Repeat the same three-wire connection for each matching front-right, rear-right, and rear-left motor and ESC. If a motor later spins the wrong way, unplug the battery and swap any two of its three motor wires.

  • Keep each ESC close to its motor so the three phase wires are short and cannot reach a propeller.

3. Make the low-voltage power supply

Before joining it to the ESP32, use a meter and adjust buck_5v_1 so VOUT is exactly 5.0 V. Connect battery_3s_1 PACK+ to buck_5v_1 VIN (power) and PACK- to buck_5v_1 GND (ground). Connect buck_5v_1 VOUT to the ESP32 5V/VIN pin (power) and its GND to an ESP32 GND pin (ground).

  • Set and measure the regulator output with the battery connected but the ESP32 disconnected first.
  • Do not use a boost converter here: a fully charged 3S LiPo is 12.6 V, so boosting it cannot make a safe 5 V supply. More than 5 V on the ESP32 5V/VIN pin can damage it.

4. Wire the motion sensor

Connect mpu6050_1 VIN to ESP32 3V3 (power), GND to ESP32 GND (ground), SDA to GPIO21 (data), and SCL to GPIO22 (clock). Mount the small board flat and firmly at the center of the frame with its printed axes aligned to the frame; do not connect its optional INT pin.

  • A foam pad can reduce vibration, but the sensor must not wobble or tilt on the pad.
  • Do not swap VIN and GND — swapped power can damage the sensor.

5. Wire the four control leads

Connect esc_fl SIGNAL to GPIO25 (signal), esc_fr SIGNAL to GPIO26 (signal), esc_rr SIGNAL to GPIO27 (signal), and esc_rl SIGNAL to GPIO33 (signal). Connect every ESC BAT+ to battery_3s_1 PACK+ (power) and every ESC BAT- to battery_3s_1 PACK- (ground); this shared negative wire is needed so the control signals have a common reference.

  • Use a proper high-current distribution board or correctly rated soldered junction for the four battery branches, not thin breadboard jumper wires.
  • Never plug four separate ESC 5 V BEC red wires into the ESP32. This design uses the buck converter instead; insulate or remove unused ESC BEC positive leads if your ESC has them.

6. Check directions without propellers

Place the frame on a clear bench, connect the LiPo, join the phone to Wi-Fi network Quad-Setup using password drone-setup, and open 192.168.4.1. Keep throttle at zero, press ARM, then increase it only enough to identify each motor. Press DISARM before touching any wire.

  • The controls are intentionally limited to 25% throttle for the first tethered checks.
  • Disconnect the LiPo before changing any wire. A LiPo short can heat wires, start a fire, and permanently damage the battery.

Review all connections

1. Connections between "battery_3s_1" and "ESP32"

Functionbattery_3s_1ESP32
powerPACK+ → 30 A 3S brushless ESC — front left BAT+EXT
powerPACK+ → 30 A 3S brushless ESC — front right BAT+EXT
powerPACK+ → 30 A 3S brushless ESC — rear right BAT+EXT
powerPACK+ → 30 A 3S brushless ESC — rear left BAT+EXT
powerPACK+ → MP1584 Buck Converter VINEXT
groundPACK- → 30 A 3S brushless ESC — front left BAT-EXT
groundPACK- → 30 A 3S brushless ESC — front right BAT-EXT
groundPACK- → 30 A 3S brushless ESC — rear right BAT-EXT
groundPACK- → 30 A 3S brushless ESC — rear left BAT-EXT
groundPACK- → MP1584 Buck Converter GNDEXT

2. Connections between "buck_5v_1" and "ESP32"

Functionbuck_5v_1ESP32
powerVOUT5V

3. Connections between "mpu6050_1" and "ESP32"

Functionmpu6050_1ESP32
powerVIN3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

4. Connections between "esc_fl" and "ESP32"

Functionesc_flESP32
pwmSIGNALGPIO 25
powerM1 → 2200 KV brushless motor — front left AEXT
powerM2 → 2200 KV brushless motor — front left BEXT
powerM3 → 2200 KV brushless motor — front left CEXT

5. Connections between "esc_fr" and "ESP32"

Functionesc_frESP32
pwmSIGNALGPIO 26
powerM1 → 2200 KV brushless motor — front right AEXT
powerM2 → 2200 KV brushless motor — front right BEXT
powerM3 → 2200 KV brushless motor — front right CEXT

6. Connections between "esc_rr" and "ESP32"

Functionesc_rrESP32
pwmSIGNALGPIO 27
powerM1 → 2200 KV brushless motor — rear right AEXT
powerM2 → 2200 KV brushless motor — rear right BEXT
powerM3 → 2200 KV brushless motor — rear right CEXT

7. Connections between "esc_rl" and "ESP32"

Functionesc_rlESP32
pwmSIGNALGPIO 33
powerM1 → 2200 KV brushless motor — rear left AEXT
powerM2 → 2200 KV brushless motor — rear left BEXT
powerM3 → 2200 KV brushless motor — rear left CEXT

Deploy the firmware

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <ESP32Servo.h>
#include <math.h>

#define ESC_FL_PIN 25
#define ESC_FR_PIN 26
#define ESC_RR_PIN 27
#define ESC_RL_PIN 33
#define MPU_SDA_PIN 21
#define MPU_SCL_PIN 22

const char *AP_NAME = "Quad-Setup";
const char *AP_PASSWORD = "drone-setup";
const uint8_t MPU_ADDR = 0x68;
const int ESC_STOP_US = 1000;
const int ESC_MAX_US = 1500;       // Deliberately limited for initial tethered tests.
const uint32_t COMMAND_TIMEOUT_MS = 400;
const float MAX_TILT_DEG = 35.0f;
const float P_GAIN = 3.5f;

Servo escFL, escFR, escRR, escRL;
WebServer server(80);

bool armed = false;
float throttleCommand = 0.0f;
float rollCommand = 0.0f;
float pitchCommand = 0.0f;
float yawCommand = 0.0f;
float rollDeg = 0.0f;
float pitchDeg = 0.0f;
uint32_t lastCommandMs = 0;
uint32_t lastImuUs = 0;

const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1">
<style>body{font-family:sans-serif;max-width:420px;margin:20px;background:#151a20;color:#eee}button,input{width:100%;margin:8px 0;font-size:18px}button{padding:12px}#arm{background:#c33;color:white}label{display:block}</style></head><body>
<h2>Quad setup control</h2><p id="status">Connect, keep propellers OFF, then arm.</p>
<button id="arm" onclick="toggleArm()">ARM</button>
<label>Throttle <output id="tv">0</output>%<input id="t" type="range" min="0" max="25" value="0" oninput="send()"></label>
<label>Roll <output id="rv">0</output><input id="r" type="range" min="-15" max="15" value="0" oninput="send()"></label>
<label>Pitch <output id="pv">0</output><input id="p" type="range" min="-15" max="15" value="0" oninput="send()"></label>
<label>Yaw <output id="yv">0</output><input id="y" type="range" min="-15" max="15" value="0" oninput="send()"></label>
<script>
let isArmed=false; const g=id=>document.getElementById(id);
function send(){['t','r','p','y'].forEach(x=>g(x+'v').textContent=g(x).value);fetch(`/set?t=${g('t').value}&r=${g('r').value}&p=${g('p').value}&y=${g('y').value}`).catch(()=>{});}
function toggleArm(){isArmed=!isArmed; if(!isArmed)g('t').value=0; fetch('/arm?state='+(isArmed?1:0)).then(()=>{g('arm').textContent=isArmed?'DISARM':'ARM';g('status').textContent=isArmed?'Armed: keep sending controls.':'Disarmed.';send();});}
setInterval(()=>{if(isArmed)send();},150); send();
</script></body></html>
)HTML";

void writeMotors(int fl, int fr, int rr, int rl) {
  escFL.writeMicroseconds(constrain(fl, ESC_STOP_US, ESC_MAX_US));
  escFR.writeMicroseconds(constrain(fr, ESC_STOP_US, ESC_MAX_US));
  escRR.writeMicroseconds(constrain(rr, ESC_STOP_US, ESC_MAX_US));
  escRL.writeMicroseconds(constrain(rl, ESC_STOP_US, ESC_MAX_US));
}

void stopMotors() { writeMotors(ESC_STOP_US, ESC_STOP_US, ESC_STOP_US, ESC_STOP_US); }

bool readImu() {
  Wire.beginTransmission(MPU_ADDR);
  Wire.write(0x3B);
  if (Wire.endTransmission(false) != 0) return false;
  if (Wire.requestFrom((int)MPU_ADDR, 14) != 14) return false;
  int16_t ax = (Wire.read() << 8) | Wire.read();
  int16_t ay = (Wire.read() << 8) | Wire.read();
  int16_t az = (Wire.read() << 8) | Wire.read();
  Wire.read(); Wire.read();
  int16_t gx = (Wire.read() << 8) | Wire.read();
  int16_t gy = (Wire.read() << 8) | Wire.read();
  Wire.read(); Wire.read();

  uint32_t nowUs = micros();
  float dt = (lastImuUs == 0) ? 0.004f : (nowUs - lastImuUs) / 1000000.0f;
  lastImuUs = nowUs;
  dt = constrain(dt, 0.001f, 0.02f);
  float accRoll = atan2f((float)ay, (float)az) * 180.0f / PI;
  float accPitch = atan2f(-(float)ax, sqrtf((float)ay * ay + (float)az * az)) * 180.0f / PI;
  rollDeg = 0.98f * (rollDeg + (gx / 131.0f) * dt) + 0.02f * accRoll;
  pitchDeg = 0.98f * (pitchDeg + (gy / 131.0f) * dt) + 0.02f * accPitch;
  return true;
}

void setupMpu() {
  Wire.beginTransmission(MPU_ADDR); Wire.write(0x6B); Wire.write(0x00); Wire.endTransmission(true);
  delay(100);
  // Start from the accelerometer tilt while the airframe is still and level.
  readImu();
}

void handleSet() {
  throttleCommand = constrain(server.arg("t").toFloat(), 0.0f, 25.0f);
  rollCommand = constrain(server.arg("r").toFloat(), -15.0f, 15.0f);
  pitchCommand = constrain(server.arg("p").toFloat(), -15.0f, 15.0f);
  yawCommand = constrain(server.arg("y").toFloat(), -15.0f, 15.0f);
  lastCommandMs = millis();
  server.send(200, "text/plain", "ok");
}

void setup() {
  Serial.begin(115200);
  escFL.setPeriodHertz(50); escFR.setPeriodHertz(50); escRR.setPeriodHertz(50); escRL.setPeriodHertz(50);
  escFL.attach(ESC_FL_PIN, 1000, 2000); escFR.attach(ESC_FR_PIN, 1000, 2000);
  escRR.attach(ESC_RR_PIN, 1000, 2000); escRL.attach(ESC_RL_PIN, 1000, 2000);
  stopMotors();
  Wire.begin(MPU_SDA_PIN, MPU_SCL_PIN);
  setupMpu();
  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_NAME, AP_PASSWORD);
  server.on("/", HTTP_GET, [](){ server.send_P(200, "text/html", INDEX_HTML); });
  server.on("/set", HTTP_GET, handleSet);
  server.on("/arm", HTTP_GET, [](){
    armed = server.arg("state") == "1";
    throttleCommand = 0.0f;
    lastCommandMs = millis();
    if (!armed) stopMotors();
    server.send(200, "text/plain", armed ? "armed" : "disarmed");
  });
  server.begin();
  Serial.println(WiFi.softAPIP());
}

void loop() {
  server.handleClient();
  bool imuOk = readImu();
  if (!armed || !imuOk || (millis() - lastCommandMs > COMMAND_TIMEOUT_MS) || fabsf(rollDeg) > MAX_TILT_DEG || fabsf(pitchDeg) > MAX_TILT_DEG) {
    armed = false;
    throttleCommand = 0.0f;
    stopMotors();
    return;
  }

  float base = ESC_STOP_US + throttleCommand * 20.0f;
  float rollCorrection = (rollCommand - rollDeg) * P_GAIN;
  float pitchCorrection = (pitchCommand - pitchDeg) * P_GAIN;
  float yawMix = yawCommand * 1.5f;
  // X frame: FL and RR rotate one way; FR and RL rotate the other way.
  writeMotors((int)(base + pitchCorrection - rollCorrection - yawMix),
              (int)(base + pitchCorrection + rollCorrection + yawMix),
              (int)(base - pitchCorrection + rollCorrection - yawMix),
              (int)(base - pitchCorrection - rollCorrection + yawMix));
}

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