← Back to list

Remote Control Self-Balancing Robot — Ongoing

Self-directed project, 2026 Winter

卓子元 (Tzu-Yuan, Cho) · 2026-01-14 03:41 · 0 claps · 7.1 min read
#robotics #pid-controller #arduino
Open on Medium ↗
Wiki topics: 📟 · Gadgets & IoT

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:

  1. Arduino UNO * 1
  2. bread board * 1
  3. brushed-DC motor * 2
  4. L298N * 1
  5. buck converter module * 1
  6. MPU6050 * 1
  7. 3D printed structures
  8. 18650 batteries * 2
  9. AT — 09 * 1
  10. 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–1 AutoCAD Inventor model of self-balancing robot

2.1–2 assembly outcome (without Wi-Fi module)

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