If you are looking for foundational projects for robotics that bridge the gap between basic blinking LEDs and autonomous navigation, a PID-controlled line-following rover is the definitive benchmark. This guide details the exact hardware, wiring, and C++ code required to build a proportional-integral-derivative (PID) line tracker using the ESP32-WROOM-32.
Unlike basic bang-bang controllers that jerk the robot left and right, a PID algorithm calculates the exact error offset from the line and applies smooth, proportional steering corrections. We are targeting the ESP32-WROOM-32 DevKit V1 (30-pin variant). This specific board variant is chosen for its dual-core processing, which allows us to run the motor control loop on Core 1 while handling serial telemetry on Core 0 without interrupt watchdog timeouts.
Project Overview & Difficulty Rating
Hardware Spec Sheet & BOM
Before ordering parts, understand why we are avoiding the ubiquitous L298N motor driver. The L298N uses bipolar junction transistors (BJTs) that drop 2V to 3V across the H-bridge. If you feed 7.4V into an L298N, your 6V motors only see ~4.5V, resulting in sluggish torque. The TB6612FNG uses MOSFETs, dropping only ~0.5V, delivering nearly full battery voltage to the motors.
| Component | Exact Variant / Model | 2026 Est. Price | Key Specification / Notes |
|---|---|---|---|
| Microcontroller | ESP32-WROOM-32 DevKit V1 (30-pin) | $6.50 | Must be 30-pin for standard breadboard fit. Avoid 38-pin for this chassis. |
| Motor Driver | Toshiba TB6612FNG (Pololu breakout) | $9.95 | Dual H-bridge, 1.2A continuous per channel. MOSFET-based (low voltage drop). |
| Drive Motors | Pololu N20 JGA25-370 (100:1 Gear Ratio) | $18.00 (pair) | 6V rated, ~210 RPM no-load. 100:1 provides high stall torque for carpet transitions. |
| Line Sensors | Pololu QRE1113 Reflectance Sensor (Analog) | $5.50 (pair) | Analog output. Do NOT buy the 'Digital' version, which lacks proportional data needed for PID. |
| Power Source | Turnigy Nano-Tech 850mAh 2S 25C LiPo | $14.00 | 7.4V nominal. 25C discharge rate easily handles motor stall current spikes. |
| Chassis Kit | SparkFun RedBot Chassis (or generic 2WD acrylic) | $22.00 | Includes casters, motor mounts, and acrylic base plates. |
Pin Mapping & Wiring Guide
The most common trap in ESP32 robotics projects is the ADC2 conflict. The ESP32's ADC2 pins (GPIO 4, 12-15, 25-27) are shared with the WiFi subsystem. If you initialize WiFi or use certain ESP32 libraries, analogRead() on ADC2 pins will silently fail and return 0. For robotics sensor arrays, you must use ADC1 pins (GPIO 32-39).
| ESP32 GPIO | Target Module Pin | Function | Hardware Notes |
|---|---|---|---|
| GPIO 34 (ADC1_CH6) | QRE1113 Left (OUT) | Left Sensor Analog Input | Input only. No internal pull-up needed. |
| GPIO 35 (ADC1_CH7) | QRE1113 Right (OUT) | Right Sensor Analog Input | Input only. Keep wires under 4 inches to reduce noise. |
| GPIO 25 (DAC1) | TB6612FNG PWMA | Left Motor PWM Speed | Use LEDC PWM channel 0. Frequency: 1000Hz. |
| GPIO 26 (DAC2) | TB6612FNG PWMB | Right Motor PWM Speed | Use LEDC PWM channel 1. Frequency: 1000Hz. |
| GPIO 27 | TB6612FNG AIN1 / AIN2 | Left Motor Direction | Wire AIN1 to GPIO 27, AIN2 to GPIO 14. |
| GPIO 14 | TB6612FNG BIN1 / BIN2 | Right Motor Direction | Wire BIN1 to GPIO 14, BIN2 to GPIO 12. |
| 3V3 Pin | TB6612FNG VCC & QRE1113 VCC | Logic Voltage (3.3V) | Do NOT connect 5V here; ESP32 logic is 3.3V tolerant. |
| VBAT / VIN | TB6612FNG VM | Motor Power (7.4V LiPo) | Connect directly to LiPo + via a physical toggle switch. |
Compilable PID Control Code
The following C++ code is written for the Arduino IDE (ESP32 board package v3.0+). It implements a custom, lightweight PID controller to avoid external library dependencies and ensure deterministic loop timing. We use the ESP32's LEDC (LED Control) peripheral for hardware-backed PWM, which prevents motor stuttering when the main loop is interrupted by serial prints.
// Target Board: ESP32-WROOM-32 DevKit V1 (30-pin)
// IDE: Arduino IDE 2.x with Espressif ESP32 Board Package v3.0+
#include <Arduino.h>
// --- PIN DEFINITIONS ---
#define SENSOR_LEFT_PIN 34 // ADC1 Channel 6
#define SENSOR_RIGHT_PIN 35 // ADC1 Channel 7
#define PWMA_PIN 25 // Left Motor PWM
#define AIN1_PIN 27 // Left Motor Dir 1
#define AIN2_PIN 14 // Left Motor Dir 2
#define PWMB_PIN 26 // Right Motor PWM
#define BIN1_PIN 12 // Right Motor Dir 1
#define BIN2_PIN 13 // Right Motor Dir 2
#define STBY_PIN 5 // TB6612FNG Standby (Active HIGH)
// --- PWM CONFIGURATION ---
#define PWM_FREQ 1000
#define PWM_RESOLUTION 8 // 0-255 duty cycle
#define LEDC_CH_LEFT 0
#define LEDC_CH_RIGHT 1
// --- PID TUNING PARAMETERS ---
// Adjust these based on Ziegler-Nichols tuning on your specific track surface
float Kp = 25.0; // Proportional gain
float Ki = 0.5; // Integral gain
float Kd = 12.0; // Derivative gain
// --- SYSTEM VARIABLES ---
int baseSpeed = 160; // Base PWM duty cycle (0-255)
int lastError = 0;
float integral = 0;
unsigned long lastTime = 0;
void setup() {
Serial.begin(115200);
delay(500); // Allow serial monitor to connect
Serial.println("ESP32 PID Line Follower Initializing...");
// Configure Motor Driver Pins
pinMode(AIN1_PIN, OUTPUT);
pinMode(AIN2_PIN, OUTPUT);
pinMode(BIN1_PIN, OUTPUT);
pinMode(BIN2_PIN, OUTPUT);
pinMode(STBY_PIN, OUTPUT);
digitalWrite(STBY_PIN, HIGH); // Take TB6612FNG out of standby
// Configure ESP32 LEDC PWM for Motors
ledcSetup(LEDC_CH_LEFT, PWM_FREQ, PWM_RESOLUTION);
ledcSetup(LEDC_CH_RIGHT, PWM_FREQ, PWM_RESOLUTION);
ledcAttachPin(PWMA_PIN, LEDC_CH_LEFT);
ledcAttachPin(PWMB_PIN, LEDC_CH_RIGHT);
// Configure Analog Sensors (ESP32 v3.0+ uses analogRead directly,
// but we set attenuation for 0-3.3V range)
analogSetAttenuation(ADC_11db);
Serial.println("System Ready. Place robot on track.");
lastTime = millis();
}
void loop() {
unsigned long currentTime = millis();
float dt = (currentTime - lastTime) / 1000.0; // Delta time in seconds
lastTime = currentTime;
// Prevent divide-by-zero or massive dt spikes on first loop
if (dt <= 0 || dt > 0.1) dt = 0.02;
// 1. Read Sensors (12-bit ADC, 0-4095)
int rawLeft = analogRead(SENSOR_LEFT_PIN);
int rawRight = analogRead(SENSOR_RIGHT_PIN);
// Error handling: If sensors read 0 or 4095 consistently, they are disconnected or saturated
if (rawLeft == 0 && rawRight == 0) {
Serial.println("[ERR] Sensors reading 0. Check 3.3V VCC and GND connections.");
stopMotors();
delay(1000);
return;
}
// 2. Calculate Error
// Assuming black line on white background.
// Higher reflectance = higher analog value.
// Error = Left - Right. Positive error means robot is drifting right of the line.
int error = rawLeft - rawRight;
// 3. PID Math
integral += error * dt;
// Anti-windup: Clamp integral to prevent massive overshoot on long straights
integral = constrain(integral, -200, 200);
float derivative = (error - lastError) / dt;
float pidOutput = (Kp * error) + (Ki * integral) + (Kd * derivative);
lastError = error;
// 4. Calculate Motor Speeds
int leftSpeed = baseSpeed + pidOutput;
int rightSpeed = baseSpeed - pidOutput;
// Constrain speeds to valid PWM range
leftSpeed = constrain(leftSpeed, -255, 255);
rightSpeed = constrain(rightSpeed, -255, 255);
// 5. Apply to Motors
driveMotor(LEDC_CH_LEFT, leftSpeed, AIN1_PIN, AIN2_PIN);
driveMotor(LEDC_CH_RIGHT, rightSpeed, BIN1_PIN, BIN2_PIN);
// Telemetry (Throttled to avoid serial buffer overflow)
static unsigned long lastPrint = 0;
if (currentTime - lastPrint > 100) {
Serial.printf("L:%d R:%d Err:%d Out:%.1f\n", rawLeft, rawRight, error, pidOutput);
lastPrint = currentTime;
}
delay(10); // Yield to RTOS background tasks (prevents WDT timeouts)
}
// --- MOTOR CONTROL FUNCTION ---
void driveMotor(int pwmChannel, int speed, int dir1Pin, int dir2Pin) {
if (speed > 0) {
digitalWrite(dir1Pin, HIGH);
digitalWrite(dir2Pin, LOW);
ledcWrite(pwmChannel, speed);
} else if (speed < 0) {
digitalWrite(dir1Pin, LOW);
digitalWrite(dir2Pin, HIGH);
ledcWrite(pwmChannel, abs(speed));
} else {
// Coast (or brake by setting both HIGH)
digitalWrite(dir1Pin, LOW);
digitalWrite(dir2Pin, LOW);
ledcWrite(pwmChannel, 0);
}
}
void stopMotors() {
ledcWrite(LEDC_CH_LEFT, 0);
ledcWrite(LEDC_CH_RIGHT, 0);
digitalWrite(AIN1_PIN, LOW); digitalWrite(AIN2_PIN, LOW);
digitalWrite(BIN1_PIN, LOW); digitalWrite(BIN2_PIN, LOW);
}
Debugging: Bootloops and Sensor I2C Failures
When moving from simulation to the physical bench, ESP32 robotics builds frequently hit hardware-level faults. If your serial monitor spits out errors, follow this decision path.
The "First Three Things to Check" Rule
- Power Rail Sag (Brownouts): Measure the 3.3V pin with a multimeter while the motors are stalled. If it drops below 3.1V, the ESP32's internal brownout detector will trigger a reset. Fix: Add a 470µF electrolytic capacitor across the 3.3V and GND pins on the breadboard.
- ADC Pin Assignment: Verify you are using GPIO 32-39. If you accidentally wired a sensor to GPIO 25 (ADC2) and later add WiFi code, your sensor readings will flatline to 0.
- TB6612FNG STBY Pin: The STBY (Standby) pin on the TB6612FNG must be pulled HIGH (3.3V) to enable the H-bridges. If left floating, the motor driver will randomly enable and disable due to EMI noise from the motors.
Exact Error Strings & Ranked Causes
Error 1: Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)
- Cause A (Most Likely): Your
loop()function lacks adelay()oryield(). The ESP32 runs FreeRTOS; if your loop hogs Core 1 without yielding, the hardware Watchdog Timer (WDT) assumes the core is locked and reboots it. Fix: Ensuredelay(10);is at the end of the loop. - Cause B: You are running heavy floating-point math inside an Interrupt Service Routine (ISR). Fix: Move math to the main loop and use ISRs only to set boolean flags.
Error 2: Brownout detector was triggered
- Cause A (Most Likely): Motor startup inrush current is pulling the shared USB/BEC voltage rail below 2.4V. Fix: Power the motors directly from the LiPo via the TB6612FNG VM pin, not from the ESP32's VIN/5V pin.
- Cause B: Faulty USB cable with high resistance (thin 28AWG data wires). Fix: Use a high-quality, short USB-C cable rated for 3A charging.
Extending and Simplifying the Build
Not every application requires a full PID loop. Depending on your end goal, you can scale this architecture up or down.
How to Simplify (The Bang-Bang Controller)
If you are teaching a beginner class or just need a robot to follow a thick black tape line without smooth cornering, strip out the PID math. Replace the analog QRE1113 sensors with digital TCRT5000 modules. These output a simple HIGH/LOW signal based on a potentiometer threshold. The code reduces to a basic if/else statement: if the left sensor sees black, turn left; if the right sees black, turn right. You lose speed and grace, but you eliminate tuning entirely.
How to Extend (Telemetry and Odometry)
To turn this into a competitive robotics platform, add two modules:
- ESP-NOW Telemetry: Because we strictly used ADC1 pins for the sensors, you are free to initialize WiFi/ESP-NOW. You can stream the
errorandpidOutputvariables wirelessly to a secondary ESP32 acting as a ground station, allowing you to plot PID response curves in real-time using the Arduino Serial Plotter or ESP-NOW protocols. - MPU6050 IMU for Odometry: Line followers are blind to their absolute position. By adding an MPU6050 via I2C (GPIO 21 for SDA, GPIO 22 for SCL), you can track the robot's yaw angle. This allows the robot to recover if it loses the line entirely, executing a search pattern based on its last known heading vector.
Bench Tip: When tuning your PID values, start with Ki and Kd set to 0. Increase Kp until the robot oscillates rapidly across the line (the critical gain). Then, set Kp to roughly 60% of that value, and slowly introduce Kd to dampen the oscillation. Add Ki last, only if the robot fails to track tight curves.






