Build your smart robot
A complete build: bill of materials, power architecture, wiring, firmware and bring-up.
Day 9 · 75 min
After this lesson
- You can assemble a differential-drive robot from an ESP32, a TB6612FNG and encoder gearmotors.
- You can design the power architecture with separate motor and logic rails.
- You can flash and test the firmware skeleton and extend it.
What you are building
The course robot is a 2-wheel differential-drive platform about 15 cm across: an ESP32 for the brain, a TB6612FNG dual H-bridge for the muscles, two N20 gearmotors with magnetic encoders for odometry, an HC-SR04 ultrasonic ranger on an SG90 servo so it can look left and right, and two TCRT5000 reflectance sensors for line following. It runs from two 18650 Li-ion cells. Every part is a commodity module, the total is about $55, and the firmware below runs obstacle avoidance and line following out of the box. It is also the robot the simulator lab models, so behaviors you prototype there carry over.
Bill of materials
| Part | Qty | Approx. price | Why this one |
|---|---|---|---|
| ESP32 DevKitC (ESP32-WROOM-32E) | 1 | $8 | Dual-core 240 MHz, Wi-Fi and Bluetooth, 16 PWM channels, PCNT encoder counters. 3.3 V logic, not 5 V tolerant. |
| TB6612FNG dual motor driver breakout | 1 | $4 | MOSFET bridges: 1.2 A continuous per channel, about 0.5 V drop at 1 A versus about 2 V for an L298N. |
| N20 gearmotor, 6 V, 100 rpm, with magnetic encoder | 2 | $10 for the pair | 150:1 gearbox, 7 encoder pulses per motor turn, so 1050 per wheel turn per channel. |
| HC-SR04 ultrasonic ranger | 1 | $2 | 2 to 400 cm, 5 V supply. The datasheet quotes a 15° measuring angle; in practice the beam is roughly 30° wide. |
| SG90 micro servo with sonar bracket | 1 | $3 | Pans the sonar 140° so the robot can choose the open side. |
| TCRT5000 reflectance module | 2 | $2 | IR LED plus phototransistor; use the analog output for a proportional reading. |
| 2 × 18650 cells (protected) with 2S holder, plus 5 V 3 A buck module | 1 set | $10 | 7.4 V nominal pack; the buck makes the logic rail. |
| Chassis plate, 43 mm wheels, ball caster, motor brackets, standoffs | 1 set | $10 | Acrylic kit or a 3D-printed plate; 120 mm between wheel centers. |
| Rocker switch, 3 A fuse, 470 µF and 220 µF capacitors, resistors (1 k, 2 k, 10 k, 47 k, 100 k), wire, headers | 1 set | $5 | The parts that make it reliable instead of flaky. |
| Total | about $54 | Plus a 2S balance charger (about $15) if you do not have one. |
Power architecture
Two rails, one ground. The motor branch runs straight from the pack (6.0 to 8.4 V) through the switch and a 3 A fuse into the TB6612's VM pin, with a 470 µF capacitor at the driver to absorb start-up spikes of up to 2 A. The logic branch goes through a 5 V buck converter into the ESP32's 5V pin; the board's AMS1117 regulator then makes 3.3 V for the chip, the driver's logic, the encoders and the line sensors. The servo and the sonar hang off 5 V. Budget the 5 V rail for the worst moment, not the average: ESP32 Wi-Fi bursts of about 0.4 A, a stalled servo at about 0.65 A and the sonar together approach 1 A, which is why the buck is a 3 A part. All grounds join at one point at the pack's negative terminal.
Two firmware rules protect the hardware. The N20s are 6 V motors on a pack that reaches 8.4 V, so the PWM duty is capped at 6.0 V divided by the measured pack voltage (71% when full). And a 100 kΩ / 47 kΩ divider lets the ESP32 read the pack, so the firmware stops the motors at 6.4 V (3.2 V per cell) before the cells are damaged.
Pin map
| Signal | ESP32 pin | Connects to | Notes |
|---|---|---|---|
| Left motor PWM, AIN1, AIN2 | GPIO 25, 26, 27 | TB6612 PWMA, AIN1, AIN2 | 20 kHz, 10-bit LEDC |
| Right motor PWM, BIN1, BIN2 | GPIO 13, 4, 16 | TB6612 PWMB, BIN1, BIN2 | Right motor mirrored: sign flipped in firmware |
| Driver standby | GPIO 17 | TB6612 STBY | 10 kΩ pull-down (the TB6612 has 200 kΩ inside too): motors off during boot |
| Left encoder A, B | GPIO 18, 19 | Left motor C1, C2 | Interrupt on A rising; encoder powered at 3.3 V |
| Right encoder A, B | GPIO 32, 33 | Right motor C1, C2 | Internal pull-ups enabled |
| Sonar trigger | GPIO 14 | HC-SR04 Trig | 3.3 V is enough to trigger |
| Sonar echo | GPIO 34 | HC-SR04 Echo via 1 kΩ / 2 kΩ | Divider makes 3.33 V; input-only pin |
| Servo signal | GPIO 23 | SG90 orange wire | 50 Hz, 0.5 to 2.4 ms |
| Line sensors left, right | GPIO 36 (VP), 39 (VN) | TCRT5000 AO | ADC1: still readable with Wi-Fi on |
| Battery sense | GPIO 35 | 100 kΩ / 47 kΩ divider after the switch | 2.69 V at 8.4 V |
| Spare: I²C for an IMU | GPIO 21 (SDA), 22 (SCL) | MPU-6050 or BNO055 | Kept free for the upgrade |
Mechanical assembly
- Bolt the motor brackets to the plate so the shafts are coaxial; aim for 120 mm between tire centers and measure it.
- Press the wheels onto the D-shafts and fit the ball caster at the rear so the plate sits level.
- Mount the 2S holder low and between the wheels: the pack is the heaviest part and belongs over the drive axle.
- Stand the ESP32 and the TB6612 on a half-size breadboard or perfboard on standoffs; twist each pair of motor leads.
- Mount the servo at the front with the sonar about 8 cm above the floor so it does not see the floor.
- Mount the two TCRT5000 boards side by side about 14 mm apart, 3 to 5 mm above the floor, ahead of the axle.
- Label every wire, strain-relieve the pack leads, and keep encoder wires away from motor leads.
Interactive: trace every wire
Wiring diagram: the course robot
Hover, tap or tab to a wire to see its signal, ESP32 pin and why it is wired that way. Filter by power or signals, then run the power-on sequence.
Logical layout: pins are grouped by what they connect to. The pin map table below gives the physical header pins.
Hover, tap or tab to a wire to see its signal, pin and purpose.
Power-on sequence
Power is off. Press Power on to watch the rails come up in order.
Firmware: the full sketch
The sketch is one file with no libraries beyond the Arduino-ESP32 core. Its shape is the one you will reuse on every robot: interrupts collect fast events (encoder edges, the sonar echo), a fixed 100 Hz control step reads sensors, updates odometry, runs the active behavior and closes a speed loop per wheel, and telemetry goes out as CSV at 10 Hz so you can plot it. Nothing calls delay() or a blocking pulseIn(), so a missing echo can never stall the motors, and a missing echo is treated as unknown, never as open space: the robot creeps, and after three missed pings in a row it stops and looks around. The listing targets the Arduino IDE 2.x with the esp32 boards package 3.x: save it as robot/robot.ino, select ESP32 Dev Module, upload, open the serial monitor at 115200 baud and type g to start obstacle avoidance, l for line following, s to stop. It also builds unchanged as main.cpp in PlatformIO, provided the platform ships Arduino-ESP32 3.x. The listing was compiled for this lesson with arduino-cli 1.5.1 and esp32 core 3.3.12 (the package the IDE's Boards Manager installs) for the ESP32 Dev Module with every compiler warning enabled, and it builds clean.
// Hardware Academy course robot
// ESP32 DevKitC + TB6612FNG + 2x N20 encoder gearmotors + HC-SR04 on an SG90 + 2x TCRT5000.
// Target: Arduino IDE 2.x with the esp32 boards package 3.x (board: ESP32 Dev Module), saved as robot/robot.ino.
// Verified October 2026: compiles with no warnings (-Wall -Wextra) on arduino-cli 1.5.1 with esp32 core 3.3.12, FQBN esp32:esp32:esp32.
// Core 3.x API: ledcAttach / ledcWrite take a pin (on core 2.x use ledcSetup + ledcAttachPin).
// Every type is declared before the first function, so the IDE's auto-generated prototypes compile;
// the same file also builds as a plain C++ main.cpp, for example in PlatformIO with an Arduino-ESP32 3.x platform.
// Serial commands at 115200 baud: g = obstacle avoidance, l = line following, s = stop.
#include <Arduino.h>
#include <esp_system.h>
// ---------- Pin map (matches the wiring diagram) ----------
constexpr uint8_t PIN_PWMA = 25, PIN_AIN1 = 26, PIN_AIN2 = 27; // left motor
constexpr uint8_t PIN_PWMB = 13, PIN_BIN1 = 4, PIN_BIN2 = 16; // right motor
constexpr uint8_t PIN_STBY = 17; // 10k pull-down to GND (the chip has 200k inside)
constexpr uint8_t PIN_ENC_LA = 18, PIN_ENC_LB = 19;
constexpr uint8_t PIN_ENC_RA = 32, PIN_ENC_RB = 33;
constexpr uint8_t PIN_TRIG = 14, PIN_ECHO = 34; // ECHO through 1k/2k divider
constexpr uint8_t PIN_SERVO = 23;
constexpr uint8_t PIN_LINE_L = 36, PIN_LINE_R = 39; // ADC1, safe with Wi-Fi on
constexpr uint8_t PIN_VBAT = 35; // 100k/47k divider from the pack
// ---------- Robot constants ----------
constexpr float WHEEL_DIAMETER_MM = 43.0f;
constexpr float TICKS_PER_REV = 1050.0f; // 7 pulses per motor turn x 150:1 (rising edges of A)
constexpr float MM_PER_TICK = PI * WHEEL_DIAMETER_MM / TICKS_PER_REV; // 0.1287 mm
constexpr float WHEEL_BASE_MM = 120.0f; // calibrate with a square test
constexpr float MAX_SPEED_MM_S = 225.0f; // 100 rpm x pi x 43 mm / 60 at 6 V
constexpr float MOTOR_RATED_V = 6.0f;
constexpr float VBAT_DIVIDER = 147.0f / 47.0f;
constexpr float VBAT_CUTOFF = 6.4f; // 3.2 V per cell
constexpr uint32_t PWM_FREQ = 20000; // 20 kHz: above hearing
constexpr uint8_t PWM_BITS = 10; // duty 0..1023
constexpr uint32_t LOOP_US = 10000; // 100 Hz control loop
constexpr uint32_t PING_MS = 60; // HC-SR04 needs ~60 ms between pings
constexpr uint32_t TELEMETRY_MS = 100; // 10 Hz
constexpr int LINE_THRESHOLD = 2000; // 12-bit ADC: calibrate on your floor and tape
// ---------- Behavior states ----------
// Declared before any function: the Arduino IDE inserts its generated prototypes (such as
// setAvoidState(AvoidState)) above the first function, so these types must already exist there.
enum class Mode { STOPPED, AVOID, LINE };
enum class AvoidState { CRUISE, LOOK, TURN };
// ---------- Motor: one TB6612 channel ----------
class Motor {
public:
Motor(uint8_t pwm, uint8_t in1, uint8_t in2, bool reversed)
: pwm_(pwm), in1_(in1), in2_(in2), reversed_(reversed) {}
void begin() {
pinMode(in1_, OUTPUT);
pinMode(in2_, OUTPUT);
ledcAttach(pwm_, PWM_FREQ, PWM_BITS);
set(0.0f, 1.0f);
}
// cmd in -1..1; maxDuty (0..1) limits the average motor voltage to its rating
void set(float cmd, float maxDuty) {
cmd = constrain(cmd, -1.0f, 1.0f);
if (reversed_) cmd = -cmd;
const uint32_t duty = (uint32_t)(fabsf(cmd) * maxDuty * ((1 << PWM_BITS) - 1));
if (duty == 0) { // IN1 = IN2 = LOW: coast
digitalWrite(in1_, LOW);
digitalWrite(in2_, LOW);
} else {
digitalWrite(in1_, cmd > 0 ? HIGH : LOW);
digitalWrite(in2_, cmd > 0 ? LOW : HIGH);
}
ledcWrite(pwm_, duty);
}
private:
uint8_t pwm_, in1_, in2_;
bool reversed_;
};
Motor leftMotor(PIN_PWMA, PIN_AIN1, PIN_AIN2, false);
Motor rightMotor(PIN_PWMB, PIN_BIN1, PIN_BIN2, true); // mounted mirrored
// ---------- Encoders: interrupt on A, direction from B ----------
volatile int32_t ticksL = 0, ticksR = 0;
portMUX_TYPE encMux = portMUX_INITIALIZER_UNLOCKED;
void IRAM_ATTR isrLeft() {
portENTER_CRITICAL_ISR(&encMux);
ticksL += digitalRead(PIN_ENC_LB) ? -1 : 1;
portEXIT_CRITICAL_ISR(&encMux);
}
void IRAM_ATTR isrRight() {
portENTER_CRITICAL_ISR(&encMux);
ticksR += digitalRead(PIN_ENC_RB) ? 1 : -1; // mirrored motor counts the other way
portEXIT_CRITICAL_ISR(&encMux);
}
void readTicks(int32_t &l, int32_t &r) {
portENTER_CRITICAL(&encMux);
l = ticksL;
r = ticksR;
portEXIT_CRITICAL(&encMux);
}
// ---------- Ultrasonic: non-blocking echo timing ----------
volatile uint32_t echoStartUs = 0, echoWidthUs = 0;
volatile bool echoReady = false;
float distanceCm = -1.0f; // last reading; -1 = unknown (no echo or silent sensor)
uint8_t missedPings = 0; // consecutive pings without a valid echo
uint32_t lastEchoMs = 0;
bool sonarDead = true; // no echo pulse at all for 250 ms: wire off or module dead
void IRAM_ATTR isrEcho() {
const uint32_t now = micros();
if (digitalRead(PIN_ECHO)) {
echoStartUs = now;
} else {
echoWidthUs = now - echoStartUs;
echoReady = true;
}
}
void startPing() {
digitalWrite(PIN_TRIG, HIGH);
delayMicroseconds(10);
digitalWrite(PIN_TRIG, LOW);
}
void updateDistance() {
if (echoReady) {
echoReady = false;
const uint32_t w = echoWidthUs;
lastEchoMs = millis();
if (w > 0 && w < 23200) { // 58 us per cm: a real echo within 4 m
distanceCm = w / 58.0f;
missedPings = 0;
} else { // ~38 ms pulse = no echo came back. An angled
distanceCm = -1.0f; // wall can reflect the ping away, so this is
if (missedPings < 255) missedPings++; // UNKNOWN, never free space.
}
}
sonarDead = millis() - lastEchoMs > 250; // not even a timeout pulse
if (sonarDead) distanceCm = -1.0f;
}
// ---------- Servo on LEDC: 50 Hz, 14-bit ----------
void servoWriteDeg(float deg) {
deg = constrain(deg, 0.0f, 180.0f);
const float us = 500.0f + deg * (1900.0f / 180.0f); // 0.5 to 2.4 ms
ledcWrite(PIN_SERVO, (uint32_t)(us / 20000.0f * 16383.0f));
}
// ---------- Line sensors and battery ----------
int lineRawL = 0, lineRawR = 0;
bool onLineL = false, onLineR = false;
float vbat = 8.0f;
void readSensors() {
lineRawL = analogRead(PIN_LINE_L); // higher over black tape
lineRawR = analogRead(PIN_LINE_R);
onLineL = lineRawL > LINE_THRESHOLD;
onLineR = lineRawR > LINE_THRESHOLD;
const float v = analogReadMilliVolts(PIN_VBAT) / 1000.0f * VBAT_DIVIDER;
vbat = 0.95f * vbat + 0.05f * v; // low-pass: motor current makes it noisy
}
// ---------- Odometry and wheel speed ----------
float poseX = 0, poseY = 0, poseTh = 0; // mm, mm, rad
float speedL = 0, speedR = 0; // mm/s
int32_t lastTicksL = 0, lastTicksR = 0;
void updateOdometry(float dt) {
int32_t l, r;
readTicks(l, r);
const float dsL = (l - lastTicksL) * MM_PER_TICK;
const float dsR = (r - lastTicksR) * MM_PER_TICK;
lastTicksL = l;
lastTicksR = r;
speedL = 0.7f * speedL + 0.3f * (dsL / dt); // ~17 ticks per 10 ms at full speed
speedR = 0.7f * speedR + 0.3f * (dsR / dt);
const float ds = 0.5f * (dsL + dsR);
const float dth = (dsR - dsL) / WHEEL_BASE_MM;
poseX += ds * cosf(poseTh + 0.5f * dth);
poseY += ds * sinf(poseTh + 0.5f * dth);
poseTh = atan2f(sinf(poseTh + dth), cosf(poseTh + dth));
}
// ---------- Per-wheel speed loop: feedforward + PI ----------
struct SpeedPI {
float kp = 0.002f; // command per mm/s of error
float ki = 0.010f; // command per (mm/s x s)
float integral = 0;
float update(float target, float measured, float dt) {
if (target == 0.0f) { integral = 0; return 0.0f; } // stop means stop
const float err = target - measured;
integral = constrain(integral + err * dt, -40.0f, 40.0f); // anti-windup: |ki x integral| <= 0.4
return target / MAX_SPEED_MM_S + kp * err + ki * integral;
}
};
SpeedPI piL, piR;
// ---------- Behaviors ----------
Mode mode = Mode::STOPPED;
AvoidState avoidState = AvoidState::CRUISE;
uint32_t stateSinceMs = 0;
float lookLeftCm = 0, lookRightCm = 0, turnStartTh = 0, turnTarget = 0;
float lastLineSide = 1; // +1: line last seen on the left
uint32_t lineSeenMs = 0;
void setAvoidState(AvoidState s) {
avoidState = s;
stateSinceMs = millis();
}
// Each behavior writes target wheel speeds in mm/s.
void obstacleAvoid(float &vL, float &vR) {
const uint32_t inState = millis() - stateSinceMs;
switch (avoidState) {
case AvoidState::CRUISE: {
servoWriteDeg(90);
if (sonarDead) { // no sonar at all: never drive blind
vL = vR = 0;
return;
}
if (missedPings >= 3) { // ~180 ms without an echo: stop and look around
setAvoidState(AvoidState::LOOK);
vL = vR = 0;
return;
}
if (distanceCm < 0.0f) { // one or two missed echoes: creep, don't assume free
vL = vR = 50.0f;
return;
}
if (distanceCm < 25.0f) { // obstacle: stop and look around
setAvoidState(AvoidState::LOOK);
vL = vR = 0;
return;
}
const float k = constrain((distanceCm - 25.0f) / 55.0f, 0.3f, 1.0f); // slow down near things
vL = vR = 160.0f * k;
return;
}
case AvoidState::LOOK: // stop, look left, look right
vL = vR = 0;
if (inState < 450) {
servoWriteDeg(160);
lookLeftCm = distanceCm; // last reading before 450 ms wins; unknown (-1) loses
} else if (inState < 900) {
servoWriteDeg(20);
lookRightCm = distanceCm;
} else {
servoWriteDeg(90);
turnStartTh = poseTh;
turnTarget = (lookLeftCm >= lookRightCm ? 1.0f : -1.0f) * HALF_PI; // + = turn left
setAvoidState(AvoidState::TURN);
}
return;
case AvoidState::TURN: {
const float turned = atan2f(sinf(poseTh - turnStartTh), cosf(poseTh - turnStartTh));
if (fabsf(turned) >= 0.95f * fabsf(turnTarget) || inState > 2500) { // done, or timed out
setAvoidState(AvoidState::CRUISE);
vL = vR = 0;
return;
}
const float s = turnTarget > 0 ? 1.0f : -1.0f;
vL = -s * 90.0f; // spin in place
vR = s * 90.0f;
return;
}
}
}
void lineFollow(float &vL, float &vR) {
// The sensors sit side by side about 14 mm apart: both over 19 mm tape when centered.
if (onLineL && !onLineR) lastLineSide = 1;
if (onLineR && !onLineL) lastLineSide = -1;
if (onLineL || onLineR) {
lineSeenMs = millis();
const float e = (onLineL ? 1.0f : 0.0f) - (onLineR ? 1.0f : 0.0f); // +1: steer left
vL = 120.0f - 70.0f * e;
vR = 120.0f + 70.0f * e;
} else if (millis() - lineSeenMs < 3000) {
vL = -60.0f * lastLineSide; // spin toward the last-seen side
vR = 60.0f * lastLineSide;
} else {
vL = vR = 0; // give up after 3 s
}
if (sonarDead || (distanceCm >= 0.0f && distanceCm < 15.0f)) {
vL = vR = 0; // never drive into something, or blind
} else if (distanceCm < 0.0f) {
vL *= 0.5f; // no echo: unknown, so slow down
vR *= 0.5f;
}
}
// ---------- 100 Hz control step ----------
float cmdL = 0, cmdR = 0;
void controlStep(float dt) {
updateOdometry(dt);
readSensors();
if (vbat < VBAT_CUTOFF && mode != Mode::STOPPED) {
mode = Mode::STOPPED;
Serial.println("battery low: stopped");
}
float vL = 0, vR = 0;
if (mode == Mode::AVOID) obstacleAvoid(vL, vR);
if (mode == Mode::LINE) lineFollow(vL, vR);
cmdL = piL.update(vL, speedL, dt);
cmdR = piR.update(vR, speedR, dt);
const float maxDuty = min(1.0f, MOTOR_RATED_V / max(vbat, 1.0f)); // 71% on a full pack
leftMotor.set(cmdL, maxDuty);
rightMotor.set(cmdR, maxDuty);
}
// ---------- Serial commands and telemetry ----------
void handleSerial() {
while (Serial.available()) {
switch (Serial.read()) {
case 'g': mode = Mode::AVOID; setAvoidState(AvoidState::CRUISE); break;
case 'l': mode = Mode::LINE; lineSeenMs = millis(); break;
case 's': mode = Mode::STOPPED; break;
default: break;
}
}
}
void printTelemetry() {
int32_t l, r;
readTicks(l, r);
// CSV: t_ms,mode,vbat,ticksL,ticksR,speedL,speedR,x_mm,y_mm,theta_deg,dist_cm,lineL,lineR,cmdL,cmdR
Serial.printf("%lu,%d,%.2f,%ld,%ld,%.0f,%.0f,%.0f,%.0f,%.1f,%.1f,%d,%d,%.2f,%.2f\n",
(unsigned long)millis(), (int)mode, vbat, (long)l, (long)r, speedL, speedR,
poseX, poseY, poseTh * RAD_TO_DEG, distanceCm, lineRawL, lineRawR, cmdL, cmdR);
}
// ---------- setup and loop ----------
uint32_t lastLoopUs = 0, lastPingMs = 0, lastTelemetryMs = 0;
void setup() {
Serial.begin(115200);
pinMode(PIN_STBY, OUTPUT);
digitalWrite(PIN_STBY, LOW); // driver off until everything is set up
leftMotor.begin();
rightMotor.begin();
pinMode(PIN_ENC_LA, INPUT_PULLUP);
pinMode(PIN_ENC_LB, INPUT_PULLUP);
pinMode(PIN_ENC_RA, INPUT_PULLUP);
pinMode(PIN_ENC_RB, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(PIN_ENC_LA), isrLeft, RISING);
attachInterrupt(digitalPinToInterrupt(PIN_ENC_RA), isrRight, RISING);
pinMode(PIN_TRIG, OUTPUT);
digitalWrite(PIN_TRIG, LOW);
pinMode(PIN_ECHO, INPUT); // GPIO 34 is input-only, no pull-up
attachInterrupt(digitalPinToInterrupt(PIN_ECHO), isrEcho, CHANGE);
ledcAttach(PIN_SERVO, 50, 14);
servoWriteDeg(90);
vbat = analogReadMilliVolts(PIN_VBAT) / 1000.0f * VBAT_DIVIDER;
Serial.printf("reset reason: %s, battery %.2f V\n",
esp_reset_reason() == ESP_RST_BROWNOUT ? "BROWNOUT" : "normal", vbat);
digitalWrite(PIN_STBY, HIGH); // H-bridges live, duty still zero
lastLoopUs = micros();
Serial.println("ready: g = obstacle avoid, l = line follow, s = stop");
}
void loop() {
handleSerial();
updateDistance();
const uint32_t nowMs = millis();
if (nowMs - lastPingMs >= PING_MS) {
lastPingMs = nowMs;
startPing();
}
if (micros() - lastLoopUs >= LOOP_US) { // fixed 100 Hz, no delay() anywhere
lastLoopUs += LOOP_US;
controlStep(LOOP_US / 1e6f);
}
if (nowMs - lastTelemetryMs >= TELEMETRY_MS) {
lastTelemetryMs = nowMs;
printTelemetry();
}
}Bring-up, safety and upgrades
Bring-up tests, in order
- Unpowered: check for shorts between pack +, 5 V, 3.3 V and GND with a multimeter.
- Set the buck to 5.0 V with nothing on its output. Many modules ship at 12 V or more, which kills an ESP32 instantly.
- First power from a bench supply at 7.4 V with a 300 mA limit and the motors unplugged: expect 5.0 V, 3.3 V and roughly 80 to 100 mA.
- Flash over USB with the 5 V jumper removed, so only USB powers the ESP32, and watch the "ready" line.
- Wheels off the ground: command each motor forward. Wrong direction: swap its leads. Counts going down: swap A and B.
- Sonar against a box at 20 cm and at 100 cm: readings within about 1 cm.
- Line sensors over tape and over floor: set LINE_THRESHOLD halfway between the two raw readings.
- Push the robot 1 m along a tape measure: expect about 7,770 ticks per wheel; correct the wheel diameter, then run a square test for the wheel base.
- Lower the bench supply to 6.3 V: the motors must stop and the log must say so.
- First autonomous run in a cardboard pen, at low speed, with a hand near the switch.
Upgrades
- ESP32-CAM (OV2640, about $8): stream video to a PC for object detection, or follow a line with a camera instead of two sensors.
- Lidar: a 360° unit such as the RPLIDAR A1 or LDROBOT LD06 (about $80 to $100, 12 m range) turns the robot into a mapping platform; a VL53L1X time-of-flight sensor is a cheap narrow-beam upgrade for the sonar.
- IMU: an MPU-6050 or BNO055 on GPIO 21 and 22 gives a gyro heading to fuse with odometry.
- micro-ROS: run a micro-ROS client on the ESP32 over Wi-Fi, publish odometry and range, subscribe to
/cmd_vel, and drive the robot from ROS 2 tools on a laptop (lesson 28). - Custom PCB: replace the breadboard with the motor-driver board from the KiCad walkthrough.
In practice
Practice
Build it: the checklist
Build the robot and work down this checklist. Each line has a measurement that proves it is done; write your number next to the expected one and keep the sheet in your project notes.
| Step | Done when | Expected value |
|---|---|---|
| Buck set | Output measured with no load | 5.00 ± 0.05 V |
| Logic rails | 3V3 pin measured with everything connected | 3.28 to 3.35 V |
| Idle current | Bench supply at 7.4 V, motors stopped, Wi-Fi off | about 80 to 100 mA |
| Encoders | One wheel turned by hand exactly once | 1050 ± 5 ticks, positive when rolling forward |
| Distance per tick | Robot pushed 1.000 m | about 7,770 ticks (0.1287 mm per tick) |
| Top speed | Both motors at the duty cap, wheels on the floor | about 200 to 225 mm/s |
| Sonar | Box at 50 cm | 49 to 51 cm |
| Battery cutoff | Bench supply lowered to 6.3 V | Motors stop, log prints "battery low" |
| Autonomy | Obstacle avoidance in a 1.5 m × 1.5 m pen with three boxes | 5 minutes without touching a box |
Worked answer: where the expected numbers come from. One wheel turn is 7 pulses per motor turn × 150 = 1050 ticks. The wheel circumference is π × 43 mm = 135.1 mm, so 1 m is 1000 / 0.1287 ≈ 7,770 ticks. Top speed is 100 rpm × 135.1 mm / 60 s = 225 mm/s at the motor's rated 6 V, a little less under load. Idle current: about 100 mA at 3.3 V (ESP32 plus the two IR LEDs) is 0.5 W, which the buck delivers from 7.4 V at about 85% efficiency, so roughly 80 mA from the pack, plus a few milliamps for the servo and sonar. If a measurement is off by more than 10%, stop and find out why before moving on: every later step depends on the earlier ones.
Real-world scenario
Real-world scenario
Works on USB, resets on battery
A student finishes the course robot. On the bench, with the ESP32 powered from the laptop's USB port and the pack switched on for the motors, it avoids boxes for half an hour while streaming telemetry over Wi-Fi. For the first real run they move the 5 V jumper so the buck powers the ESP32, unplug USB and put the robot on the floor. It drives for a second or two, stops, and starts its boot sequence again, over and over.
Symptoms
- After each reboot the firmware logs esp_reset_reason() = ESP_RST_BROWNOUT
- On USB power the same firmware runs for 30 minutes without a single reset
- A scope on the 5 V rail shows dips to about 3.9 V lasting 2 ms each time both motors start
- Resets get more frequent as the pack discharges and almost stop when Wi-Fi telemetry is turned off
Interview questions
screenWhy do you power the motors and the microcontroller from separate rails, and what must they still share?
screenThe HC-SR04 is a 5 V part and the ESP32 is a 3.3 V part. How do you connect them?
screenWhy does this firmware cap the PWM duty at about 70%?
deepWalk me through bringing up a new robot board for the first time.
deepHow do you read two quadrature encoders on an ESP32 without missing counts?
Quiz
References and further reading
Where the ideas in this lesson come from, and what to read next. Books are the standard texts; papers are the original sources; standards are what employers work to.
- Documentationfree online
Toshiba (2014). TB6612FNG: Driver IC for Dual DC Motor (datasheet). toshiba.semicon-storage.com.
Read: Ratings and the pin table behind the STBY pull-down and the 1.2 A per channel.
- Book
Roland Siegwart, Illah R. Nourbakhsh and Davide Scaramuzza (2011). Introduction to Autonomous Mobile Robots. MIT Press, 2nd edition.
Read: The locomotion and sensors chapters, to understand each part you are wiring.
- Book
Elecia White (2024). Making Embedded Systems: Design Patterns for Great Software. O'Reilly, 2nd edition.
Read: The firmware-structure chapters before you extend the sketch.
- Book
Paul Horowitz and Winfield Hill (2015). The Art of Electronics. Cambridge University Press, 3rd edition.
Read: Power supplies and motor driving.