LFB Mark 2: ESP32 PID Line Follower With OLED Parameter Tuning

by Mr_AKN in Circuits > Robots

99 Views, 2 Favorites, 0 Comments

LFB Mark 2: ESP32 PID Line Follower With OLED Parameter Tuning

IMG_20260608_111712.jpg
IMG_20260608_111727.jpg
IMG_20260608_113642.jpg
Screenshot 2026-06-06 123051.png

LFB Mark 2 is my attempt at building a faster and smarter line follower robot. MY goal was to move away from simple if-else line following and experiment with PID control, live parameter tuning, and a cleaner robot architecture.

The robot uses a 5-channel IR sensor array to detect the line and a TB6612 motor driver to control two N20 gear motors. An OLED display and three buttons were added to make PID tuning easier without connecting a laptop every time.

This project involved a lot of trial and error, debugging, redesigning, and unfortunately one fallen ESP32 along the way.

Supplies

IMG_20260606_121640.jpg

Supplies

Electronics

  1. ESP32 Development Board
  2. TB6612FNG Dual Motor Driver
  3. TCRT5000L 5-Channel Line Tracking Sensor Module
  4. SSD1306 128×64 I2C OLED Display
  5. 3 × Push Buttons
  6. 2 × N20 12V 600 RPM Metal Gear Motors
  7. 2 × 18650 Li-ion Cells
  8. Battery Holders
  9. Power Switch
  10. Jumper Wires
  11. Perfboard / Prototype PCB

Mechanical Parts

  1. 3D Printed Chassis (Downloaded from Printables)
  2. 2 × N20 Motor Mounts
  3. Mounting Hardware (Screws, Nuts)
  4. 2 × 44mm MiniQ Robot Wheels
  5. Mini Ball Caster Wheel

Tools

  1. Soldering Iron
  2. Solder Wire
  3. Wire Cutter
  4. Screwdriver Set
  5. Double side tape/Hot Glue Gun (Optional)

Software

  1. Arduino IDE
  2. Adafruit GFX Library
  3. Adafruit SSD1306 Library
  4. ESP32 Board Package for Arduino IDE
  5. Ultimaker Cura (for slicing the 3D model)

Why Build Another Line Follower?

IMG_20260608_111734.jpg
IMG_20260608_111759.jpg

After building and experimenting with different robotics projects, I wanted to try a proper PID-based line follower. The main objective was to create a robot that could follow a line smoothly while allowing real-time tuning of parameters like Kp, Kd, Ki, and speed.

Printing the Chassis

IMG_20260606_123850.jpg
Screenshot 2026-06-06 124508.png

Rather than designing a chassis from scratch, I used a 3D-printable line follower robot chassis from Printables - Line Follower Robot Chassis. The design already included mounting points for the N20 motors, caster wheel, batteries, and electronics, which helped speed up the development process. Full credit goes to the original designer.

Building the Chassis

IMG_20260606_130336.jpg
IMG_20260606_130529.jpg
IMG_20260606_130917.jpg
IMG_20260606_131052.jpg
IMG_20260606_131059.jpg

With the chassis printed, it was time to start turning a piece of plastic into an actual robot.

The N20 motors were mounted on both sides and fitted with 44 mm MiniQ wheels. A caster wheel was added to keep the robot balanced while still allowing it to turn freely.

The battery holders were mounted as low as possible on the chassis. Keeping the batteries low helps lower the robot's center of gravity, making it more stable during fast turns and sudden corrections.

The 5-channel IR sensor array was mounted at the front of the robot. Placing the sensors ahead of the wheels allows the robot to detect changes in the line earlier, giving the PID controller more time to react and make smoother corrections.

Once the mechanical assembly was complete, the robot was ready for the electronics and wiring stage.

Electronics Assembly

IMG_20260606_135155.jpg
IMG_20260606_134557.jpg
IMG_20260519_082847.jpg
IMG_20260519_124317.jpg
IMG_20260519_124332.jpg
IMG_20260519_124341.jpg
IMG_20260519_124421.jpg

The ESP32 acts as the brain of the robot, reading data from the sensor array and calculating the PID correction required to stay on the line. Motor control is handled by a TB6612FNG motor driver, which drives the two N20 gear motors.

To make tuning easier, I added a 128×64 OLED display along with three push buttons. This allows PID parameters and speed to be adjusted directly from the robot without connecting a laptop every time a change is needed.

The 5-channel IR sensor array is mounted at the front of the robot and provides digital line detection data for the PID controller.

After a lot of wiring, soldering, and checking connections multiple times, everything was assembled onto a perfboard to keep the wiring compact and reliable.

Final Pin Mapping

Sensors

S1 - GPIO19

S2 - GPIO18

S3 - GPIO5

S4 - GPIO17

S5 - GPIO16

OLED Display

SDA - GPIO21

SCL - GPIO22

Buttons

Increase - GPIO36

Decrease - GPIO39

Select - GPIO34

Button Pull-up Resistors

Since GPIO34, GPIO36, and GPIO39 do not support internal pull-up resistors, each button was connected with an external 10kΩ pull-up resistor to 3.3V.

When a button is pressed, the corresponding GPIO pin is pulled LOW. This keeps the button inputs stable and prevents false triggering due to floating inputs.

TB6612FNG Motor Driver

PWMA - GPIO13

AIN2 - GPIO14

AIN1 - GPIO27

STBY - GPIO26

BIN1 - GPIO25

BIN2 - GPIO33

PWMB - GPIO32

Power Connections

2S 18650 Battery Pack (+) - TB6612 VM

2S 18650 Battery Pack (-) - Common GND

TB6612 VCC - ESP32 3.3V

TB6612 GND - Common GND

ESP32 VIN - Battery Positive (through power switch)

ESP32 GND - Common GND

Sensor VCC - ESP32 3.3V

Sensor GND - Common GND

OLED VCC - ESP32 3.3V

OLED GND - Common GND

All grounds were connected together to create a common reference between the ESP32, motor driver, OLED display, and sensor module.

During development I initially tried using GPIO36 as an output before realizing it is input-only. This required rewiring part of the circuit and served as a reminder to always check ESP32 pin capabilities before finalizing a design.

Downloads

Programming the Robot

Screenshot 2026-06-07 202416.png
Screenshot 2026-06-07 203001.png
Screenshot 2026-06-07 203036.png
Screenshot 2026-06-07 203521.png

The software side of this project went through several revisions before reaching its current form.

Instead of making simple left-or-right decisions based on the sensor readings, I decided to use a weighted PID approach. Each sensor is assigned a weight, allowing the robot to estimate its position relative to the line and calculate how much correction is needed.

An OLED display and three buttons were added to make tuning easier. This allowed me to adjust Kp, Kd, Ki, and base speed directly from the robot without constantly reconnecting it to a computer.

Along the way, I experimented with different recovery methods, junction handling strategies, sensor weightings, and PID values. Some ideas worked surprisingly well, while others mostly taught me what not to do.

To make debugging easier, the robot also outputs sensor patterns, motor speeds, and PID information through the serial monitor during testing.


#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64

Adafruit_SSD1306 display(
SCREEN_WIDTH,
SCREEN_HEIGHT,
&Wire,
-1
);

// Sensors
#define S1 19
#define S2 18
#define S3 5
#define S4 17
#define S5 16

// Buttons
#define BTN_INC 36
#define BTN_DEC 39
#define BTN_SELECT 34

// TB6612
#define PWMA 13
#define AIN2 14
#define AIN1 27

#define STBY 26

#define BIN1 25
#define BIN2 33
#define PWMB 32

// PWM
#define PWM_FREQ 20000
#define PWM_RESOLUTION 8

#define DEBUG_MODE true

bool robotRunning = false;

bool invertLeftMotor = false;
bool invertRightMotor = true;

float Kp = 22.0;
float Kd = 12.0;
float Ki = 0.0;

float error = 0;
float previousError = 0;
float integral = 0;
float derivative = 0;
float correction = 0;

int normalBaseSpeed = 180;
int junctionBaseSpeed = 120;

int currentBaseSpeed = 180;

int leftMotorSpeed = 0;
int rightMotorSpeed = 0;

int sensor[5];

int weights[5] = {-4, -2, 0, 2, 4};

String patternString = "";

int selectedParameter = 0;

unsigned long lastLoopTime = 0;
unsigned long lastOLEDTime = 0;
unsigned long lastDebugTime = 0;

const int loopInterval = 5;
const int oledInterval = 100;
const int debugInterval = 100;

bool lastSelectState = HIGH;

unsigned long pressStartTime = 0;

void readSensors();
float calculatePosition();
void calculatePID();
void updateMotors();
void setMotor(int leftSpeed, int rightSpeed);
void stopMotors();
void updateOLED();
void handleButtons();
void serialDebug();
bool isJunction();
bool isLostLine();

void setup()
{
Serial.begin(115200);

pinMode(S1, INPUT);
pinMode(S2, INPUT);
pinMode(S3, INPUT);
pinMode(S4, INPUT);
pinMode(S5, INPUT);

pinMode(BTN_INC, INPUT);
pinMode(BTN_DEC, INPUT);
pinMode(BTN_SELECT, INPUT);

pinMode(AIN1, OUTPUT);
pinMode(AIN2, OUTPUT);

pinMode(BIN1, OUTPUT);
pinMode(BIN2, OUTPUT);

pinMode(STBY, OUTPUT);

digitalWrite(STBY, HIGH);

ledcAttach(PWMA, PWM_FREQ, PWM_RESOLUTION);
ledcAttach(PWMB, PWM_FREQ, PWM_RESOLUTION);

if(!display.begin(SSD1306_SWITCHCAPVCC, 0x3C))
{
while(true);
}

display.clearDisplay();
display.setTextSize(1);
display.setTextColor(WHITE);

display.setCursor(15, 20);
display.println("LFB MARK2");

display.display();

delay(1000);
}

void loop()
{
handleButtons();

unsigned long currentMillis = millis();

if(currentMillis - lastLoopTime >= loopInterval)
{
lastLoopTime = currentMillis;

if(robotRunning)
{
readSensors();

if(isLostLine())
{
if(previousError < 0)
{
setMotor(-120, 180);
}
else
{
setMotor(180, -120);
}

return;
}

else
{
if(isJunction())
{
currentBaseSpeed = junctionBaseSpeed;
}
else
{
currentBaseSpeed = normalBaseSpeed;
}

error = calculatePosition();

calculatePID();

updateMotors();
}
}
else
{
stopMotors();
}
}

if(currentMillis - lastOLEDTime >= oledInterval)
{
lastOLEDTime = currentMillis;

updateOLED();
}

if(DEBUG_MODE && currentMillis - lastDebugTime >= debugInterval)
{
lastDebugTime = currentMillis;

serialDebug();
}
}

void readSensors()
{
sensor[0] = digitalRead(S1);
sensor[1] = digitalRead(S2);
sensor[2] = digitalRead(S3);
sensor[3] = digitalRead(S4);
sensor[4] = digitalRead(S5);

patternString = "";

for(int i = 0; i < 5; i++)
{
patternString += String(sensor[i]);
}
}

float calculatePosition()
{
int weightedSum = 0;
int activeSensors = 0;

for(int i = 0; i < 5; i++)
{
if(sensor[i] == 0)
{
weightedSum += weights[i];
activeSensors++;
}
}

if(activeSensors == 0)
{
return previousError;
}

return (float)weightedSum / activeSensors;
}

void calculatePID()
{
integral += error;

derivative = error - previousError;

correction =
(Kp * error)
+ (Ki * integral)
+ (Kd * derivative);

previousError = error;
}

void updateMotors()
{
leftMotorSpeed =
currentBaseSpeed + correction;

rightMotorSpeed =
currentBaseSpeed - correction;

leftMotorSpeed =
constrain(leftMotorSpeed, -255, 255);

rightMotorSpeed =
constrain(rightMotorSpeed, -255, 255);

setMotor(leftMotorSpeed, rightMotorSpeed);
}

void setMotor(int leftSpeed, int rightSpeed)
{
bool leftForward = leftSpeed >= 0;

int leftPWM = abs(leftSpeed);

if(invertLeftMotor)
{
leftForward = !leftForward;
}

digitalWrite(AIN1, leftForward);
digitalWrite(AIN2, !leftForward);

ledcWrite(PWMA, leftPWM);

bool rightForward = rightSpeed >= 0;

int rightPWM = abs(rightSpeed);

if(invertRightMotor)
{
rightForward = !rightForward;
}

digitalWrite(BIN1, rightForward);
digitalWrite(BIN2, !rightForward);

ledcWrite(PWMB, rightPWM);
}

void stopMotors()
{
ledcWrite(PWMA, 0);
ledcWrite(PWMB, 0);
}

bool isJunction()
{
if(patternString == "00011") return true;
if(patternString == "11000") return true;
if(patternString == "00000") return true;

return false;
}

bool isLostLine()
{
if(patternString == "11111")
{
return true;
}

return false;
}

void updateOLED()
{
display.clearDisplay();

display.setCursor(0, 0);
display.print("KP:");
display.print(Kp);

if(selectedParameter == 0)
{
display.print(" <");
}

display.setCursor(0, 16);
display.print("KD:");
display.print(Kd);

if(selectedParameter == 1)
{
display.print(" <");
}

display.setCursor(0, 32);
display.print("KI:");
display.print(Ki);

if(selectedParameter == 2)
{
display.print(" <");
}

display.setCursor(0, 48);
display.print("SPD:");
display.print(normalBaseSpeed);

if(selectedParameter == 3)
{
display.print(" <");
}

display.setCursor(70, 48);

if(robotRunning)
{
display.print("RUN");
}
else
{
display.print("STOP");
}

display.display();
}

void handleButtons()
{
bool selectState = digitalRead(BTN_SELECT);

if(lastSelectState == HIGH && selectState == LOW)
{
pressStartTime = millis();
}

if(lastSelectState == LOW && selectState == HIGH)
{
unsigned long pressDuration =
millis() - pressStartTime;

if(pressDuration > 700)
{
robotRunning = !robotRunning;
}
else
{
selectedParameter++;

if(selectedParameter > 3)
{
selectedParameter = 0;
}
}
}

lastSelectState = selectState;

if(digitalRead(BTN_INC) == LOW)
{
switch(selectedParameter)
{
case 0:
Kp += 0.5;
break;

case 1:
Kd += 0.5;
break;

case 2:
Ki += 0.05;
break;

case 3:
normalBaseSpeed += 5;
break;
}

delay(150);
}

if(digitalRead(BTN_DEC) == LOW)
{
switch(selectedParameter)
{
case 0:
Kp -= 0.5;
break;

case 1:
Kd -= 0.5;
break;

case 2:
Ki -= 0.05;
break;

case 3:
normalBaseSpeed -= 5;
break;
}

delay(150);
}
}

void serialDebug()
{
Serial.print(patternString);

Serial.print(" ");

Serial.print(leftMotorSpeed);

Serial.print(" ");

Serial.print(rightMotorSpeed);

Serial.print(" ");

if(isLostLine())
{
Serial.print("LOST");
}
else if(isJunction())
{
Serial.print("JUNCTION");
}
else if(error < 0)
{
Serial.print("LEFT");
}
else if(error > 0)
{
Serial.print("RIGHT");
}
else
{
Serial.print("STRAIGHT");
}

Serial.println();
}

Debugging and Lessons Learned

Screenshot 2026-06-07 204334.png
Screenshot 2026-06-07 205113.png
IMG_20260608_111825.jpg

This is actually the fun section.

Like most robotics projects, this one spent a lot more time being debugged than being photographed.

Another surprise came from the ESP32 PWM API. Some examples online used functions that were no longer supported in the version of the ESP32 core I was using, which meant parts of the motor control code had to be rewritten.

The robot also went through several different recovery strategies. Initially, the robot would stop completely whenever all sensors lost the line. While this was useful for debugging, it wasn't very practical on an actual track. After experimenting with different approaches, I settled on a recovery method that allows the robot to continue moving and reacquire the line more smoothly.

The button interface also required some attention. Without proper debouncing, a single button press could be detected multiple times, making parameter tuning frustrating.

The biggest challenge, however, was PID tuning. Small changes in Kp, Kd, and speed could completely change how the robot behaved. Finding a balance between stability and speed took many test runs and adjustments.

And finally, no project is complete without at least one unexpected casualty. During development, one ESP32 board unfortunately sacrificed itself for the advancement of the project.

Results

IMG_20260608_112433.jpg
IMG_20260608_112428.jpg
IMG_20260608_113817.jpg
IMG_20260608_113819.jpg
IMG_20260608_113822.jpg
IMG_20260608_111712.jpg
IMG_20260608_111727.jpg
IMG_20260608_111150.jpg
IMG_20260608_111325.jpg
IMG_20260608_111326.jpg

After multiple iterations and tuning sessions, the robot was able to follow the track using weighted PID control. The OLED interface allowed quick parameter adjustments, making testing much easier.

The final result is a compact line follower robot that is faster, smarter, and far more configurable than my earlier attempts.

Working video attached below.