Self-levelling platform
Tilt the base and two servos tilt the plate the other way, so a marble stays put. An MPU-6050 measures the tilt and a complementary filter keeps it steady.
Wiring
| From | To board | Note |
|---|---|---|
| MPU-6050 VCC | 3V3 | The GY-521 board also accepts 5 V, but 3V3 keeps its I2C lines at the ESP32's level |
| MPU-6050 GND | GND | |
| MPU-6050 SDA | GPIO 21 | The board has its own pull-up resistors |
| MPU-6050 SCL | GPIO 22 | |
| Roll servo signal | GPIO 18 | Tilts the plate left and right |
| Pitch servo signal | GPIO 19 | Tilts the plate forward and back |
| Both servos red | 5 V supply + | Not the ESP32's 3V3 pin. Two servos moving at once can draw an amp |
| Both servos brown, and supply - | GND | Join the servo supply's ground to the ESP32's GND |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32 Dev Module
// Self-levelling platform: an MPU-6050 on the base measures how far it is
// tilted, and two servos tilt the plate the other way so it stays level.
// Talks to the MPU-6050 directly over I2C, so no extra library is needed.
#include <Wire.h>
#include <ESP32Servo.h>
const int SDA_PIN = 21;
const int SCL_PIN = 22;
const int SERVO_X_PIN = 18; // tilts the plate left and right (roll)
const int SERVO_Y_PIN = 19; // tilts the plate forward and back (pitch)
const uint8_t MPU = 0x68; // 0x69 if the board's AD0 pin is tied high
const float ALPHA = 0.98f; // complementary filter: 98 % gyro, 2 % accelerometer
const float GAIN_X = 1.0f; // servo degrees per degree of tilt; depends on your linkage
const float GAIN_Y = 1.0f;
const bool INVERT_X = false; // flip if a servo makes the tilt worse instead of better
const bool INVERT_Y = false;
const int CENTER = 90, TRAVEL = 45; // servo centre and how far either way it may go
Servo servoX, servoY;
float gyroBiasX = 0, gyroBiasY = 0;
float pitch = 0, roll = 0; // filtered tilt, degrees
float pitchZero = 0, rollZero = 0; // the tilt at start-up counts as level
unsigned long lastUs = 0;
void writeReg(uint8_t reg, uint8_t value) {
Wire.beginTransmission(MPU);
Wire.write(reg);
Wire.write(value);
Wire.endTransmission();
}
// Accelerometer in g and gyro in degrees per second, from one 14-byte read.
bool readMpu(float& ax, float& ay, float& az, float& gx, float& gy) {
Wire.beginTransmission(MPU);
Wire.write(0x3B); // ACCEL_XOUT_H
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(MPU, (uint8_t)14) != 14) return false;
int16_t raw[7];
for (int i = 0; i < 7; i++) raw[i] = (int16_t)((Wire.read() << 8) | Wire.read());
ax = raw[0] / 16384.0f; // ±2 g range
ay = raw[1] / 16384.0f;
az = raw[2] / 16384.0f;
gx = raw[4] / 131.0f; // ±250 °/s range; raw[3] is temperature
gy = raw[5] / 131.0f;
return true;
}
void accelAngles(float ax, float ay, float az, float& p, float& r) {
p = degrees(atan2f(-ax, sqrtf(ay * ay + az * az)));
r = degrees(atan2f(ay, az));
}
void setup() {
Serial.begin(115200);
Wire.begin(SDA_PIN, SCL_PIN, 400000);
Wire.beginTransmission(MPU);
if (Wire.endTransmission() != 0) {
Serial.println("MPU-6050 not found at 0x68: check SDA, SCL, 3V3 and GND");
while (true) delay(1000);
}
writeReg(0x6B, 0x00); // PWR_MGMT_1: wake up
writeReg(0x1A, 0x03); // CONFIG: 44 Hz low-pass filter to calm the servos
writeReg(0x1B, 0x00); // GYRO_CONFIG: ±250 °/s
writeReg(0x1C, 0x00); // ACCEL_CONFIG: ±2 g
servoX.setPeriodHertz(50);
servoY.setPeriodHertz(50);
servoX.attach(SERVO_X_PIN, 500, 2400);
servoY.attach(SERVO_Y_PIN, 500, 2400);
servoX.write(CENTER);
servoY.write(CENTER);
// Calibrate: hold still for two seconds while the gyro's drift and the
// starting tilt are measured.
Serial.println("Calibrating: keep the base still and level");
delay(500);
float sumGx = 0, sumGy = 0, sumP = 0, sumR = 0;
int n = 0;
for (int i = 0; i < 400; i++) {
float ax, ay, az, gx, gy;
if (readMpu(ax, ay, az, gx, gy)) {
float p, r;
accelAngles(ax, ay, az, p, r);
sumGx += gx;
sumGy += gy;
sumP += p;
sumR += r;
n++;
}
delay(5);
}
if (n > 0) {
gyroBiasX = sumGx / n;
gyroBiasY = sumGy / n;
pitchZero = sumP / n;
rollZero = sumR / n;
}
pitch = pitchZero;
roll = rollZero;
lastUs = micros();
Serial.println("Levelling. Tilt the base and watch the plate.");
}
unsigned long lastPrint = 0;
void loop() {
float ax, ay, az, gx, gy;
if (!readMpu(ax, ay, az, gx, gy)) {
Serial.println("MPU-6050 read failed");
delay(100);
return;
}
const unsigned long now = micros();
const float dt = (now - lastUs) / 1e6f;
lastUs = now;
// Complementary filter: the gyro is smooth but drifts, the accelerometer is
// noisy but never drifts. Mix them.
float ap, ar;
accelAngles(ax, ay, az, ap, ar);
roll = ALPHA * (roll + (gx - gyroBiasX) * dt) + (1 - ALPHA) * ar;
pitch = ALPHA * (pitch + (gy - gyroBiasY) * dt) + (1 - ALPHA) * ap;
// Tilt the plate against the base's tilt.
const float tiltX = roll - rollZero, tiltY = pitch - pitchZero;
const int sx = constrain((int)roundf(CENTER + (INVERT_X ? 1 : -1) * GAIN_X * tiltX), CENTER - TRAVEL, CENTER + TRAVEL);
const int sy = constrain((int)roundf(CENTER + (INVERT_Y ? 1 : -1) * GAIN_Y * tiltY), CENTER - TRAVEL, CENTER + TRAVEL);
servoX.write(sx);
servoY.write(sy);
if (millis() - lastPrint >= 100) {
lastPrint = millis();
Serial.printf("pitch = %.1f, roll = %.1f, servo_x = %d, servo_y = %d\n", tiltY, tiltX, sx, sy);
}
delay(5);
}Wiring
| From | To board | Note |
|---|---|---|
| MPU-6050 VCC | 3V3 | The GY-521 board also accepts 5 V, but 3V3 keeps its I2C lines at the ESP32's level |
| MPU-6050 GND | GND | |
| MPU-6050 SDA | GPIO 8 | The board has its own pull-up resistors |
| MPU-6050 SCL | GPIO 9 | |
| Roll servo signal | GPIO 5 | Tilts the plate left and right |
| Pitch servo signal | GPIO 6 | Tilts the plate forward and back |
| Both servos red | 5 V supply + | Not the ESP32's 3V3 pin. Two servos moving at once can draw an amp |
| Both servos brown, and supply - | GND | Join the servo supply's ground to the ESP32's GND |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32S3 Dev Module
// Self-levelling platform: an MPU-6050 on the base measures how far it is
// tilted, and two servos tilt the plate the other way so it stays level.
// Talks to the MPU-6050 directly over I2C, so no extra library is needed.
#include <Wire.h>
#include <ESP32Servo.h>
const int SDA_PIN = 8;
const int SCL_PIN = 9;
const int SERVO_X_PIN = 5; // tilts the plate left and right (roll)
const int SERVO_Y_PIN = 6; // tilts the plate forward and back (pitch)
const uint8_t MPU = 0x68; // 0x69 if the board's AD0 pin is tied high
const float ALPHA = 0.98f; // complementary filter: 98 % gyro, 2 % accelerometer
const float GAIN_X = 1.0f; // servo degrees per degree of tilt; depends on your linkage
const float GAIN_Y = 1.0f;
const bool INVERT_X = false; // flip if a servo makes the tilt worse instead of better
const bool INVERT_Y = false;
const int CENTER = 90, TRAVEL = 45; // servo centre and how far either way it may go
Servo servoX, servoY;
float gyroBiasX = 0, gyroBiasY = 0;
float pitch = 0, roll = 0; // filtered tilt, degrees
float pitchZero = 0, rollZero = 0; // the tilt at start-up counts as level
unsigned long lastUs = 0;
void writeReg(uint8_t reg, uint8_t value) {
Wire.beginTransmission(MPU);
Wire.write(reg);
Wire.write(value);
Wire.endTransmission();
}
// Accelerometer in g and gyro in degrees per second, from one 14-byte read.
bool readMpu(float& ax, float& ay, float& az, float& gx, float& gy) {
Wire.beginTransmission(MPU);
Wire.write(0x3B); // ACCEL_XOUT_H
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(MPU, (uint8_t)14) != 14) return false;
int16_t raw[7];
for (int i = 0; i < 7; i++) raw[i] = (int16_t)((Wire.read() << 8) | Wire.read());
ax = raw[0] / 16384.0f; // ±2 g range
ay = raw[1] / 16384.0f;
az = raw[2] / 16384.0f;
gx = raw[4] / 131.0f; // ±250 °/s range; raw[3] is temperature
gy = raw[5] / 131.0f;
return true;
}
void accelAngles(float ax, float ay, float az, float& p, float& r) {
p = degrees(atan2f(-ax, sqrtf(ay * ay + az * az)));
r = degrees(atan2f(ay, az));
}
void setup() {
Serial.begin(115200);
Wire.begin(SDA_PIN, SCL_PIN, 400000);
Wire.beginTransmission(MPU);
if (Wire.endTransmission() != 0) {
Serial.println("MPU-6050 not found at 0x68: check SDA, SCL, 3V3 and GND");
while (true) delay(1000);
}
writeReg(0x6B, 0x00); // PWR_MGMT_1: wake up
writeReg(0x1A, 0x03); // CONFIG: 44 Hz low-pass filter to calm the servos
writeReg(0x1B, 0x00); // GYRO_CONFIG: ±250 °/s
writeReg(0x1C, 0x00); // ACCEL_CONFIG: ±2 g
servoX.setPeriodHertz(50);
servoY.setPeriodHertz(50);
servoX.attach(SERVO_X_PIN, 500, 2400);
servoY.attach(SERVO_Y_PIN, 500, 2400);
servoX.write(CENTER);
servoY.write(CENTER);
// Calibrate: hold still for two seconds while the gyro's drift and the
// starting tilt are measured.
Serial.println("Calibrating: keep the base still and level");
delay(500);
float sumGx = 0, sumGy = 0, sumP = 0, sumR = 0;
int n = 0;
for (int i = 0; i < 400; i++) {
float ax, ay, az, gx, gy;
if (readMpu(ax, ay, az, gx, gy)) {
float p, r;
accelAngles(ax, ay, az, p, r);
sumGx += gx;
sumGy += gy;
sumP += p;
sumR += r;
n++;
}
delay(5);
}
if (n > 0) {
gyroBiasX = sumGx / n;
gyroBiasY = sumGy / n;
pitchZero = sumP / n;
rollZero = sumR / n;
}
pitch = pitchZero;
roll = rollZero;
lastUs = micros();
Serial.println("Levelling. Tilt the base and watch the plate.");
}
unsigned long lastPrint = 0;
void loop() {
float ax, ay, az, gx, gy;
if (!readMpu(ax, ay, az, gx, gy)) {
Serial.println("MPU-6050 read failed");
delay(100);
return;
}
const unsigned long now = micros();
const float dt = (now - lastUs) / 1e6f;
lastUs = now;
// Complementary filter: the gyro is smooth but drifts, the accelerometer is
// noisy but never drifts. Mix them.
float ap, ar;
accelAngles(ax, ay, az, ap, ar);
roll = ALPHA * (roll + (gx - gyroBiasX) * dt) + (1 - ALPHA) * ar;
pitch = ALPHA * (pitch + (gy - gyroBiasY) * dt) + (1 - ALPHA) * ap;
// Tilt the plate against the base's tilt.
const float tiltX = roll - rollZero, tiltY = pitch - pitchZero;
const int sx = constrain((int)roundf(CENTER + (INVERT_X ? 1 : -1) * GAIN_X * tiltX), CENTER - TRAVEL, CENTER + TRAVEL);
const int sy = constrain((int)roundf(CENTER + (INVERT_Y ? 1 : -1) * GAIN_Y * tiltY), CENTER - TRAVEL, CENTER + TRAVEL);
servoX.write(sx);
servoY.write(sy);
if (millis() - lastPrint >= 100) {
lastPrint = millis();
Serial.printf("pitch = %.1f, roll = %.1f, servo_x = %d, servo_y = %d\n", tiltY, tiltX, sx, sy);
}
delay(5);
}Wiring
| From | To board | Note |
|---|---|---|
| MPU-6050 VCC | 3V3 | The GY-521 board also accepts 5 V, but 3V3 keeps its I2C lines at the ESP32's level |
| MPU-6050 GND | GND | |
| MPU-6050 SDA | GPIO 6 | The board has its own pull-up resistors |
| MPU-6050 SCL | GPIO 7 | |
| Roll servo signal | GPIO 4 | Tilts the plate left and right |
| Pitch servo signal | GPIO 5 | Tilts the plate forward and back |
| Both servos red | 5 V supply + | Not the ESP32's 3V3 pin. Two servos moving at once can draw an amp |
| Both servos brown, and supply - | GND | Join the servo supply's ground to the ESP32's GND |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32C3 Dev Module
// Self-levelling platform: an MPU-6050 on the base measures how far it is
// tilted, and two servos tilt the plate the other way so it stays level.
// Talks to the MPU-6050 directly over I2C, so no extra library is needed.
#include <Wire.h>
#include <ESP32Servo.h>
const int SDA_PIN = 6;
const int SCL_PIN = 7;
const int SERVO_X_PIN = 4; // tilts the plate left and right (roll)
const int SERVO_Y_PIN = 5; // tilts the plate forward and back (pitch)
const uint8_t MPU = 0x68; // 0x69 if the board's AD0 pin is tied high
const float ALPHA = 0.98f; // complementary filter: 98 % gyro, 2 % accelerometer
const float GAIN_X = 1.0f; // servo degrees per degree of tilt; depends on your linkage
const float GAIN_Y = 1.0f;
const bool INVERT_X = false; // flip if a servo makes the tilt worse instead of better
const bool INVERT_Y = false;
const int CENTER = 90, TRAVEL = 45; // servo centre and how far either way it may go
Servo servoX, servoY;
float gyroBiasX = 0, gyroBiasY = 0;
float pitch = 0, roll = 0; // filtered tilt, degrees
float pitchZero = 0, rollZero = 0; // the tilt at start-up counts as level
unsigned long lastUs = 0;
void writeReg(uint8_t reg, uint8_t value) {
Wire.beginTransmission(MPU);
Wire.write(reg);
Wire.write(value);
Wire.endTransmission();
}
// Accelerometer in g and gyro in degrees per second, from one 14-byte read.
bool readMpu(float& ax, float& ay, float& az, float& gx, float& gy) {
Wire.beginTransmission(MPU);
Wire.write(0x3B); // ACCEL_XOUT_H
if (Wire.endTransmission(false) != 0) return false;
if (Wire.requestFrom(MPU, (uint8_t)14) != 14) return false;
int16_t raw[7];
for (int i = 0; i < 7; i++) raw[i] = (int16_t)((Wire.read() << 8) | Wire.read());
ax = raw[0] / 16384.0f; // ±2 g range
ay = raw[1] / 16384.0f;
az = raw[2] / 16384.0f;
gx = raw[4] / 131.0f; // ±250 °/s range; raw[3] is temperature
gy = raw[5] / 131.0f;
return true;
}
void accelAngles(float ax, float ay, float az, float& p, float& r) {
p = degrees(atan2f(-ax, sqrtf(ay * ay + az * az)));
r = degrees(atan2f(ay, az));
}
void setup() {
Serial.begin(115200);
Wire.begin(SDA_PIN, SCL_PIN, 400000);
Wire.beginTransmission(MPU);
if (Wire.endTransmission() != 0) {
Serial.println("MPU-6050 not found at 0x68: check SDA, SCL, 3V3 and GND");
while (true) delay(1000);
}
writeReg(0x6B, 0x00); // PWR_MGMT_1: wake up
writeReg(0x1A, 0x03); // CONFIG: 44 Hz low-pass filter to calm the servos
writeReg(0x1B, 0x00); // GYRO_CONFIG: ±250 °/s
writeReg(0x1C, 0x00); // ACCEL_CONFIG: ±2 g
servoX.setPeriodHertz(50);
servoY.setPeriodHertz(50);
servoX.attach(SERVO_X_PIN, 500, 2400);
servoY.attach(SERVO_Y_PIN, 500, 2400);
servoX.write(CENTER);
servoY.write(CENTER);
// Calibrate: hold still for two seconds while the gyro's drift and the
// starting tilt are measured.
Serial.println("Calibrating: keep the base still and level");
delay(500);
float sumGx = 0, sumGy = 0, sumP = 0, sumR = 0;
int n = 0;
for (int i = 0; i < 400; i++) {
float ax, ay, az, gx, gy;
if (readMpu(ax, ay, az, gx, gy)) {
float p, r;
accelAngles(ax, ay, az, p, r);
sumGx += gx;
sumGy += gy;
sumP += p;
sumR += r;
n++;
}
delay(5);
}
if (n > 0) {
gyroBiasX = sumGx / n;
gyroBiasY = sumGy / n;
pitchZero = sumP / n;
rollZero = sumR / n;
}
pitch = pitchZero;
roll = rollZero;
lastUs = micros();
Serial.println("Levelling. Tilt the base and watch the plate.");
}
unsigned long lastPrint = 0;
void loop() {
float ax, ay, az, gx, gy;
if (!readMpu(ax, ay, az, gx, gy)) {
Serial.println("MPU-6050 read failed");
delay(100);
return;
}
const unsigned long now = micros();
const float dt = (now - lastUs) / 1e6f;
lastUs = now;
// Complementary filter: the gyro is smooth but drifts, the accelerometer is
// noisy but never drifts. Mix them.
float ap, ar;
accelAngles(ax, ay, az, ap, ar);
roll = ALPHA * (roll + (gx - gyroBiasX) * dt) + (1 - ALPHA) * ar;
pitch = ALPHA * (pitch + (gy - gyroBiasY) * dt) + (1 - ALPHA) * ap;
// Tilt the plate against the base's tilt.
const float tiltX = roll - rollZero, tiltY = pitch - pitchZero;
const int sx = constrain((int)roundf(CENTER + (INVERT_X ? 1 : -1) * GAIN_X * tiltX), CENTER - TRAVEL, CENTER + TRAVEL);
const int sy = constrain((int)roundf(CENTER + (INVERT_Y ? 1 : -1) * GAIN_Y * tiltY), CENTER - TRAVEL, CENTER + TRAVEL);
servoX.write(sx);
servoY.write(sy);
if (millis() - lastPrint >= 100) {
lastPrint = millis();
Serial.printf("pitch = %.1f, roll = %.1f, servo_x = %d, servo_y = %d\n", tiltY, tiltX, sx, sy);
}
delay(5);
}Parts
| Quantity | Part | Where to buy |
|---|---|---|
| 1× | ESP32 dev board | Buy at Amazon |
| 1× | MPU-6050 accelerometer and gyro board (GY-521) | |
| 2× | Micro servos, SG90 or similar | Buy at Adafruit |
| 1× | 5 V supply for the servos, such as a USB power bank or a 5 V 2 A adapter | |
| 1× | Small plate and base: card, plywood or a 3D print | |
| 10× | Jumper wires |
Amazon buy links may include an Associates tag, and this site may earn a commission from qualifying Amazon purchases at no extra cost to you. Adafruit links are direct.
Libraries
Wire (built in), ESP32Servo
Load it
- In the Arduino IDE, install the esp32 board package and pick the board named above the sketch.
- Install the libraries listed above from the Library Manager.
- Paste the sketch, choose the board's port and upload. Hold BOOT if the upload stalls at Connecting.
- Close the IDE's serial monitor, then open the bench here to watch the output.
Field notes
- Fix the MPU-6050 to the base, not the plate. The sketch measures how far the base tilts and turns the servos the other way.
- Keep the base still and level for the first two seconds after power-up: the sketch measures the gyro's drift and takes the starting position as level.
- If the plate tilts the wrong way on one axis, set INVERT_X or INVERT_Y. If it under- or over-corrects, change GAIN_X and GAIN_Y: the right value depends on how far each servo arm moves the plate.
- The complementary filter trusts the gyro 98 % (ALPHA) for smooth, quick moves and the accelerometer 2 % to stop the gyro drifting. Lower ALPHA if it slowly wanders, raise it if it jitters.
- Many cheap MPU-6050 boards are clones. If the board is not found, run the I2C scanner project: it may answer at 0x69 if its AD0 pin is high.
- Servos that jitter or reset the board are short of current. Power them from their own 5 V supply and join the grounds.
Not tested yet
- Not tested on hardware yet. Compiles for every board listed with Arduino-ESP32 core 3.3.12.
Community builds
Loading build reports from people who made this.
Built it? Send a build report with your board and what the serial port printed. Working reports become the "Tested on this board" entries above.