Remote Control Self-Balancing Robot — Ongoing
Self-directed project, 2026 Winter
Remote Control Self-Balancing Robot
Self-directed project, 2025 Winter
I learned that PID-based self-balancing robots are a popular project for junior- or senior-level control courses among U.S. undergraduate students. I found this project both simple and interesting and decide to build one at home using tools I already have. To make the project more distinctive, I also plan to add a remote control function.
1. Objective
This project involves building a remote-controllable, PID-based self-balancing robot using a microcontroller, IMUs, Wi-Fi modules, and other related components. The goal is to achieve stable balance at the robot’s equilibrium point in every attempt, while also enabling smooth forward, backward, and turning motions through a remote controller.
2. Prototyping
2.1 Components
I first plan to build a prototype by the following components:
- Arduino UNO * 1
- bread board * 1
- brushed-DC motor * 2
- L298N * 1
- buck converter module * 1
- MPU6050 * 1
- 3D printed structures
- 18650 batteries * 2
- AT — 09 * 1
- Nuts & Bolts
No encoder included cause I haven’t bought one yet XD, but will be added in future project.
2.2 Robot Structure Modeling & assembly
I used AutoCAD Inventor as modeling software this time since it’s free for students, the following are the modeling results and what it actually looks like after 3D printing and assembly.

2.1–1 AutoCAD Inventor model of self-balancing robot

2.1–2 assembly outcome (without Wi-Fi module)
2.3 Coding
I used MPU6050 by Electronic Cats in Arduino’s library to read the roll angle I need for feedback control.
As for controller and main function:
// PID controller parameters
float targetAngle = 0.0; // default as 0, will change by center of mass
float Kp = 200;
float Ki = 0.0;
float Kd = 40.0;
float error = 0;
float lastError = 0;
float integral = 0;
float derivative = 0;
unsigned long lastTime = 0;
// motor pin setting
// 2,3 high -->> backward 1,4 high -->> forward
const int ENB = 6; // PWM
const int IN4 = 7;
const int IN3 = 8;
const int IN2 = 9;
const int IN1 = 10;
const int ENA = 11; // PWM
void loop() {
if (!DMPReady) return;
/* Read a packet from FIFO */
if (mpu.dmpGetCurrentFIFOPacket(FIFOBuffer)) { // Get the Latest packet
/*Display quaternion values in easy matrix form: w x y z */
mpu.dmpGetQuaternion(&q, FIFOBuffer);
mpu.dmpGetGravity(&gravity, &q);
/* Display initial world-frame acceleration, adjusted to remove gravity
and rotated based on known orientation from Quaternion */
mpu.dmpGetAccel(&aa, FIFOBuffer);
mpu.dmpConvertToWorldFrame(&aaWorld, &aa, &q);
/* Display initial world-frame acceleration, adjusted to remove gravity
and rotated based on known orientation from Quaternion */
mpu.dmpGetGyro(&gg, FIFOBuffer);
mpu.dmpConvertToWorldFrame(&ggWorld, &gg, &q);
/* Display Euler angles in degrees */
mpu.dmpGetYawPitchRoll(ypr, &q, &gravity);
// PID controller
float roll = ypr[2] * RAD_TO_DEG;
unsigned long now = millis();
float dt = (now - lastTime) / 1000.0;
if (dt <= 0) dt = 0.001;
lastTime = now;
float currentAngle = roll; // feedback signal
error = targetAngle - currentAngle;
integral += error * dt;
derivative = (error - lastError) / dt;
float output = Kp * error + Ki * integral + Kd * derivative;
lastError = error;
driveMotor(output);
delay(5);
}
}
void driveMotor(float u) {
int pwm = constrain(abs(u), 0, 255);
if (u > 0) { // leaning forward → move forward
digitalWrite(IN1, HIGH);
digitalWrite(IN2, LOW);
digitalWrite(IN4, HIGH);
digitalWrite(IN3, LOW);
} else { // leaning backword → move backward
digitalWrite(IN1, LOW);
digitalWrite(IN2, HIGH);
digitalWrite(IN4, LOW);
digitalWrite(IN3, HIGH);
}
analogWrite(ENA, pwm*0.925); // adjusting motor torque difference
analogWrite(ENB, pwm);
}
2.4 First Stage Result
[embed]
2.5 Problems Encountered
a. Unstable Dupont wire connections (causing failure in the video)
The performance of the system is negatively affected by unstable Dupont wire connections. Poor contact and frequent bending of the wires often lead to intermittent signal loss or power interruption. Over time, repeated bending causes the wires to degrade or break, reducing overall system reliability.
b. backlash of brushed-DC motor
Mechanical backlash in the brushed DC motors introduces delays and inaccuracies in motor response. This backlash makes precise control more difficult, especially when rapid or small corrective movements are required to maintain balance, ultimately affecting the stability of the control system.
c. lack of rotational speed feedback
Can’t accurately control robot to stay in desired position. And combined with the dislocation of center of the mass, the robot sometimes tends moves toward a certain direction if not placed properly right before running the program.
3. Second stage (remote control)
I added a AT-09 Bluetooth module to my robot and tried to control it’s movement by Dabble (app for Bluetooth control ).
3.1 Coding
Added two additional functions (readBluetooth() & parseCommand())for reading Bluetooth signals and sending control commands. ()
#include "I2Cdev.h"
#include "MPU6050_6Axis_MotionApps20.h"
#include <SoftwareSerial.h>
/* MPU6050 default I2C address is 0x68*/
MPU6050 mpu;
//MPU6050 mpu(0x69); //Use for AD0 high
//MPU6050 mpu(0x68, &Wire1); //Use for AD0 low, but 2nd Wire (TWI/I2C) object.
/*Conversion variables*/
#define EARTH_GRAVITY_MS2 9.80665 //m/s2
#define DEG_TO_RAD 0.017453292519943295769236907684886
#define RAD_TO_DEG 57.295779513082320876798154814105
/*---MPU6050 Control/Status Variables---*/
bool DMPReady = false; // Set true if DMP init was successful
uint8_t MPUIntStatus; // Holds actual interrupt status byte from MPU
uint8_t devStatus; // Return status after each device operation (0 = success, !0 = error)
uint16_t packetSize; // Expected DMP packet size (default is 42 bytes)
uint8_t FIFOBuffer[64]; // FIFO storage buffer
/*---MPU6050 Control/Status Variables---*/
Quaternion q; // [w, x, y, z] Quaternion container
VectorInt16 aa; // [x, y, z] Accel sensor measurements
VectorInt16 gg; // [x, y, z] Gyro sensor measurements
VectorInt16 aaWorld; // [x, y, z] World-frame accel sensor measurements
VectorInt16 ggWorld; // [x, y, z] World-frame gyro sensor measurements
VectorFloat gravity; // [x, y, z] Gravity vector
float euler[3]; // [psi, theta, phi] Euler angle container
float ypr[3]; // [yaw, pitch, roll] Yaw/Pitch/Roll container and gravity vector
// bluetooth pin
SoftwareSerial bt(2, 3); // RX, TX
// PID controller parameters
float targetAngle = 0.0;
float Kp = 200;
float Ki = 0.0;
float Kd = 40.0;
float error = 0;
float lastError = 0;
float integral = 0;
float derivative = 0;
unsigned long lastTime = 0;
// motor pinmode setting
// 2,3 high -->> backward 1,4 high -->> forward
const int ENB = 6;
const int IN4 = 7;
const int IN3 = 8;
const int IN2 = 9;
const int IN1 = 10;
const int ENA = 11;
float angleOffset = 0;
float balanceTrim = angleOffset;
// bluetooth parameters
uint8_t btBuf[7];
uint8_t idx = 0;
volatile float tiltcmd = 0.0;
volatile float turncmd = 0.0;
void setup() {
Serial.begin(115200);
bt.begin(9600); // AT-09
#if I2CDEV_IMPLEMENTATION == I2CDEV_ARDUINO_WIRE
Wire.begin();
Wire.setClock(400000); // 400kHz I2C clock. Comment on this line if having compilation difficulties
#elif I2CDEV_IMPLEMENTATION == I2CDEV_BUILTIN_FASTWIRE
Fastwire::setup(400, true);
#endif
//Serial.begin(115200);
Serial.println(F("Initializing I2C devices..."));
mpu.initialize();
Serial.println(F("Testing MPU6050 connection..."));
if(mpu.testConnection() == false){
Serial.println("MPU6050 connection failed");
while(true);
}
else {
Serial.println("MPU6050 connection successful");
}
/* Initializate and configure the DMP*/
Serial.println(F("Initializing DMP..."));
devStatus = mpu.dmpInitialize();
/* Supply your gyro offsets here, scaled for min sensitivity */
mpu.setXGyroOffset(54);
mpu.setYGyroOffset(-22);
mpu.setZGyroOffset(39);
mpu.setXAccelOffset(-2549);
mpu.setYAccelOffset(1561);
mpu.setZAccelOffset(1305);
/* Making sure it worked (returns 0 if so) */
if (devStatus == 0) {
mpu.CalibrateAccel(6); // Calibration Time: generate offsets and calibrate our MPU6050
mpu.CalibrateGyro(6);
Serial.println("These are the Active offsets: ");
mpu.PrintActiveOffsets();
Serial.println(F("Enabling DMP...")); //Turning ON DMP
mpu.setDMPEnabled(true);
MPUIntStatus = mpu.getIntStatus();
/* Set the DMP Ready flag so the main loop() function knows it is okay to use it */
Serial.println(F("DMP ready! Waiting for first interrupt..."));
DMPReady = true;
packetSize = mpu.dmpGetFIFOPacketSize(); //Get expected DMP packet size for later comparison
}
pinMode(ENA, OUTPUT);
pinMode(IN1, OUTPUT);
pinMode(IN2, OUTPUT);
pinMode(ENB, OUTPUT);
pinMode(IN3, OUTPUT);
pinMode(IN4, OUTPUT);
digitalWrite(IN1, LOW);
digitalWrite(IN2, LOW);
analogWrite(ENA, 0);
digitalWrite(IN3, LOW);
digitalWrite(IN4, LOW);
analogWrite(ENB, 0);
//calibrateAngle();
targetAngle = angleOffset;
}
void loop() {
if (mpu.getFIFOCount() == 1024) {
mpu.resetFIFO();
}
if (!DMPReady) return;
/* Read a packet from FIFO */
if (mpu.dmpGetCurrentFIFOPacket(FIFOBuffer)) { // Get the Latest packet
/*Display quaternion values in easy matrix form: w x y z */
mpu.dmpGetQuaternion(&q, FIFOBuffer);
mpu.dmpGetGravity(&gravity, &q);
mpu.dmpGetAccel(&aa, FIFOBuffer);
mpu.dmpConvertToWorldFrame(&aaWorld, &aa, &q);
mpu.dmpGetGyro(&gg, FIFOBuffer);
mpu.dmpConvertToWorldFrame(&ggWorld, &gg, &q);
mpu.dmpGetYawPitchRoll(ypr, &q, &gravity);
// --- balance point correction ---
float trimGain = 0.0005;
balanceTrim += error * trimGain;
balanceTrim = constrain(balanceTrim, -5.0, 5.0);
float effectiveTarget = targetAngle + balanceTrim;
// read bluetooth signal
readBluetooth();
// PID controller
float roll = ypr[2] * RAD_TO_DEG;
float rollRate = -ggWorld.x * mpu.get_gyro_resolution(); // deg/s
unsigned long now = micros();
float dt = (now - lastTime) * 1e-6;
if (dt <= 0 || dt > 0.02) dt = 0.005;
lastTime = now;
float error = effectiveTarget - roll + tiltcmd;
integral += error * dt;
float output = Kp * error + Kd * rollRate + Ki * integral;
driveMotor(output,turncmd);
}
}
void driveMotor(float u, float turn) {
int base = constrain(abs(u), 0, 255);
int leftpwm = constrain(base - turn, 0, 255);
int rightpwm = constrain(base + turn, 0, 255);
if (u > 0) { // forward
digitalWrite(IN1, HIGH);
digitalWrite(IN2, LOW);
digitalWrite(IN4, HIGH);
digitalWrite(IN3, LOW);
} else if(u < 0){ // backward
digitalWrite(IN1, LOW);
digitalWrite(IN2, HIGH);
digitalWrite(IN4, LOW);
digitalWrite(IN3, HIGH);
}
else {
digitalWrite(IN1, LOW);
digitalWrite(IN2, LOW);
digitalWrite(IN4, LOW);
digitalWrite(IN3, LOW);
}
analogWrite(ENA, leftpwm*0.925);
analogWrite(ENB, rightpwm);
}
void readBluetooth() {
while (bt.available()) {
uint8_t b = bt.read();
// all signal reseived starts with 1 1 1 2
if (idx == 0 && b != 1) continue;
if (idx == 1 && b != 1) { idx = 0; continue; }
if (idx == 2 && b != 1) { idx = 0; continue; }
if (idx == 3 && b != 2) { idx = 0; continue; }
btBuf[idx++] = b;
if (idx == 7) {
parseCommand(btBuf);
idx = 0;
}
}
}
void parseCommand(uint8_t *buf) {
uint8_t cmd = buf[5]; // difference between differnt buttens only on buf[5]
switch(cmd){
case 0:
tiltcmd = 0;
turncmd = 0;
break;
case 1:// forward
tiltcmd = 1.5;
break;
case 2:// backward
tiltcmd = -1.5;
break;
case 4:// left
turncmd = 10.0;
break;
case 8:// right
turncmd = -10.0;
break;
}
}
4.Results
The whole system is kind of wobbly and I guess it’s due to the insufficient torque of motor and also the lack of encoder for speed feedback. But for now it can move forward, backward and turn by a Bluetooth controller
[embed]low voltage can’t handle turning movement and unstable even just forward and backward movement
[embed]higher voltage input enabled moving in all direction but also system oscillation, need further tunning
5. Future Work
a. costumed PCD design
A custom-designed PCB will replace Dupont wire connections to improve electrical reliability and mechanical robustness. This will reduce signal noise, prevent poor contacts, and enhance the overall durability of the system.
b. changing motor to servo or adding encoder to current motor
The current brushed DC motors may be replaced with servo motors to achieve more precise position and speed control. Alternatively, encoders can be added to the existing motors to provide real-time feedback, allowing for closed-loop motor control and reduced effects of mechanical backlash.
메타데이터
- post_id
- 034d8e5cfe82
- slug
- remote-control-self-balancing-robot-ongoing-034d8e5cfe82
- url
- https://medium.com/@P_35301/remote-control-self-balancing-robot-ongoing-034d8e5cfe82
- canonical_url
- https://medium.com/@P_35301/remote-control-self-balancing-robot-ongoing-034d8e5cfe82
- author_url
- https://medium.com/@P_35301
- status
- ok
- fetched_at
- 2026-06-09 15:37:30