Hardware Academy
Robotics

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

PartQtyApprox. priceWhy this one
ESP32 DevKitC (ESP32-WROOM-32E)1$8Dual-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 breakout1$4MOSFET 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 encoder2$10 for the pair150:1 gearbox, 7 encoder pulses per motor turn, so 1050 per wheel turn per channel.
HC-SR04 ultrasonic ranger1$22 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 bracket1$3Pans the sonar 140° so the robot can choose the open side.
TCRT5000 reflectance module2$2IR LED plus phototransistor; use the analog output for a proportional reading.
2 × 18650 cells (protected) with 2S holder, plus 5 V 3 A buck module1 set$107.4 V nominal pack; the buck makes the logic rail.
Chassis plate, 43 mm wheels, ball caster, motor brackets, standoffs1 set$10Acrylic 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, headers1 set$5The parts that make it reliable instead of flaky.
Totalabout $54Plus 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

SignalESP32 pinConnects toNotes
Left motor PWM, AIN1, AIN2GPIO 25, 26, 27TB6612 PWMA, AIN1, AIN220 kHz, 10-bit LEDC
Right motor PWM, BIN1, BIN2GPIO 13, 4, 16TB6612 PWMB, BIN1, BIN2Right motor mirrored: sign flipped in firmware
Driver standbyGPIO 17TB6612 STBY10 kΩ pull-down (the TB6612 has 200 kΩ inside too): motors off during boot
Left encoder A, BGPIO 18, 19Left motor C1, C2Interrupt on A rising; encoder powered at 3.3 V
Right encoder A, BGPIO 32, 33Right motor C1, C2Internal pull-ups enabled
Sonar triggerGPIO 14HC-SR04 Trig3.3 V is enough to trigger
Sonar echoGPIO 34HC-SR04 Echo via 1 kΩ / 2 kΩDivider makes 3.33 V; input-only pin
Servo signalGPIO 23SG90 orange wire50 Hz, 0.5 to 2.4 ms
Line sensors left, rightGPIO 36 (VP), 39 (VN)TCRT5000 AOADC1: still readable with Wi-Fi on
Battery senseGPIO 35100 kΩ / 47 kΩ divider after the switch2.69 V at 8.4 V
Spare: I²C for an IMUGPIO 21 (SDA), 22 (SCL)MPU-6050 or BNO055Kept free for the upgrade

Mechanical assembly

  1. Bolt the motor brackets to the plate so the shafts are coaxial; aim for 120 mm between tire centers and measure it.
  2. Press the wheels onto the D-shafts and fit the ball caster at the rear so the plate sits level.
  3. Mount the 2S holder low and between the wheels: the pack is the heaviest part and belongs over the drive axle.
  4. Stand the ESP32 and the TB6612 on a half-size breadboard or perfboard on standoffs; twist each pair of motor leads.
  5. Mount the servo at the front with the sonar about 8 cm above the floor so it does not see the floor.
  6. Mount the two TCRT5000 boards side by side about 14 mm apart, 3 to 5 mm above the floor, ahead of the axle.
  7. Label every wire, strain-relieve the pack leads, and keep encoder wires away from motor leads.
Power architecture and wiring overview: pack, switch and fuse, motor branch through the TB6612, logic branch through the 5 V buck and the ESP32's 3.3 V regulator2S 18650 pack6.0-8.4 V7.4 V nominalSwitch+ 3 A fuseVM, up to 2 A peaks470 µFTB6612FNG2 × H-bridge1.2 A per channelLeft N20 motorRight N20 motor5 V buck3 A, ~90% efficient5 V, ≤ 1 A220 µFESP32 DevKitC5V pin → AMS1117LDO → 3.3 VSG90 + HC-SR045 V loads3.3 V loadsdriver logic2 encoders2 line sensors≈ 0.15 A7 control lines4 encoder linesEvery ground symbol is one net, joined at a star point at the pack minus.Motor return current goes straight back to the pack, not through the ESP32.Never feed the ESP32's 5V pin from the buck and USB at the same time.
Power architecture and wiring overview. The motor branch takes raw pack voltage; the logic branch is regulated twice (buck to 5 V, LDO to 3.3 V). Bulk capacitors sit where the current spikes are.

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.

Wiring diagram of the ESP32 robot: pack, buck, ESP32, TB6612, motors, sonar, servo and line sensors2S Li-ion pack7.4 V nom · 8.4 V fullSwitch + 3 A fuse5 V buck (3 A)set to 5.0 V firstESP32 DevKitC3.3 V logicTB6612FNGdual H-bridgeLeft motorN20 + encoderRight motorN20 + encoderHC-SR045 V sonarSG90 servopans the sonarTCRT5000leftTCRT5000right18192526271341617323314342336395V3V335GNDPWMAAIN1AIN2PWMBBIN1BIN2STBYVMVCCGNDAO1AO2BO1BO23V33V33V33V33V33V3GNDGNDGNDGNDGNDGNDGNDGNDGNDGND1k/2k÷3.1pack 7.4 V5 V3.3 Vdriver logicencoderssensorsmotor leads
View

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.

robot.inocpp
// 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

  1. Unpowered: check for shorts between pack +, 5 V, 3.3 V and GND with a multimeter.
  2. 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.
  3. 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.
  4. Flash over USB with the 5 V jumper removed, so only USB powers the ESP32, and watch the "ready" line.
  5. Wheels off the ground: command each motor forward. Wrong direction: swap its leads. Counts going down: swap A and B.
  6. Sonar against a box at 20 cm and at 100 cm: readings within about 1 cm.
  7. Line sensors over tape and over floor: set LINE_THRESHOLD halfway between the two raw readings.
  8. 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.
  9. Lower the bench supply to 6.3 V: the motors must stop and the log must say so.
  10. 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.

StepDone whenExpected value
Buck setOutput measured with no load5.00 ± 0.05 V
Logic rails3V3 pin measured with everything connected3.28 to 3.35 V
Idle currentBench supply at 7.4 V, motors stopped, Wi-Fi offabout 80 to 100 mA
EncodersOne wheel turned by hand exactly once1050 ± 5 ticks, positive when rolling forward
Distance per tickRobot pushed 1.000 mabout 7,770 ticks (0.1287 mm per tick)
Top speedBoth motors at the duty cap, wheels on the floorabout 200 to 225 mm/s
SonarBox at 50 cm49 to 51 cm
Battery cutoffBench supply lowered to 6.3 VMotors stop, log prints "battery low"
AutonomyObstacle avoidance in a 1.5 m × 1.5 m pen with three boxes5 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?
Motors draw large, fast current spikes at start-up and reversal, and their brushes inject noise, so a motor on the same regulated rail as the microcontroller can drag it below its brownout threshold. On this robot the motors run from the pack through the TB6612's VM pin, while a buck makes a clean 5 V rail and the ESP32's LDO makes 3.3 V. The rails must share ground, because every logic level is measured relative to it; without a common ground the driver's PWM input floats. I would also route the motor return current straight back to the pack so it does not flow through the ESP32's ground wire, and put bulk capacitors at the driver.
screenThe HC-SR04 is a 5 V part and the ESP32 is a 3.3 V part. How do you connect them?
The trigger input works with 3.3 V because its threshold is below that, so TRIG goes straight to a GPIO. The echo output swings to 5 V and ESP32 pins are not 5 V tolerant, so I would put a divider in it: 1 kΩ in series and 2 kΩ to ground gives 5 × 2/3 = 3.33 V. Alternatives are a level-shifter chip or the HC-SR04P variant that runs at 3.3 V. I would also measure the echo with an interrupt rather than a blocking pulseIn, so a missing echo cannot stall the control loop for 38 ms.
screenWhy does this firmware cap the PWM duty at about 70%?
The N20 motors are rated 6 V but the 2S pack is 8.4 V when full, and the average voltage a motor sees is roughly duty times supply. Capping duty at 6.0 V divided by the measured pack voltage, 71% on a full pack, keeps the motor at its rated voltage, which protects the brushes and the plastic gearbox and keeps top speed consistent as the pack drains. Because the cap is computed from the battery reading every tick, the robot behaves the same at 8.4 V and at 7.0 V. It is a cheap form of supply compensation that also makes the PID gains valid over the whole discharge.
deepWalk me through bringing up a new robot board for the first time.
I start unpowered: visual inspection, then continuity checks for shorts between each rail and ground. Next I set any adjustable regulator with nothing connected to its output, because many buck modules ship set to 12 V or more. First power comes from a bench supply with a current limit, around 300 mA, and I check every rail and the idle current against my estimate. Then I flash firmware with the motor driver held in standby and bring up one subsystem at a time: serial logs, motors with the wheels off the ground, encoders, each sensor against a known reference. Only after that do I enable closed-loop behaviors, at low speed, with a hand near the power switch, and I log everything so a failure can be replayed.
deepHow do you read two quadrature encoders on an ESP32 without missing counts?
First estimate the rate: 1050 pulses per wheel turn at 100 rpm is 1750 Hz per channel, which an interrupt on the rising edge of channel A handles easily; the ISR reads channel B to get the direction. The ISR must be short and marked IRAM_ATTR, the counters volatile, and the main loop must read and clear them inside a critical section so it never sees a half-updated value. For higher rates, or to count all four edges, I would use the ESP32's PCNT pulse-counter peripheral, which counts in hardware with a glitch filter and no CPU load. And I would keep the encoder wires away from the motor leads, because PWM noise on an encoder line shows up as phantom counts.

Quiz

1. The HC-SR04 echo (5 V) goes through 1 kΩ in series with 2 kΩ to ground. What voltage reaches the ESP32 pin?
2. Why must the ESP32, the TB6612 and every sensor share a ground?
3. The motors are rated 6 V and the pack reads 8.4 V. What PWM duty cap keeps the average motor voltage at 6 V?
4. Which pins should the analog line sensors use on an ESP32 that also runs Wi-Fi?
5. With 1050 pulses per wheel turn on channel A, how often does that channel pulse when the wheel turns at 100 rpm?
6. Why is the TB6612's STBY pin held low until setup() has configured the PWM pins?
7. A Li-ion cell in the robot is hot and starting to swell. What do you do?

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.

  1. 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.

  2. 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.

  3. 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.

  4. Book

    Paul Horowitz and Winfield Hill (2015). The Art of Electronics. Cambridge University Press, 3rd edition.

    Read: Power supplies and motor driving.