Education › Robotics Engineering › Guided project

An obstacle-avoiding rover

Build a rover on an ESP32 that senses what is ahead with an ultrasonic sensor, avoids obstacles on its own, and can be driven from your phone over Wi-Fi — combining sensing, non-blocking control and a tiny web server into one robot with two modes.

Intermediate about 8 hours 6 phases · 25 steps 0 / 25 done
What you will have at the end

A rover that roams a room avoiding walls and furniture in autonomous mode, and switches to manual control from a web page it serves on your phone — with a safety stop that overrides you if you steer into something.

Before you start
  • The line-following project, or equivalent comfort with wiring a motor driver and driving two DC motors from code
  • The Robotics lessons through Sensors and Control — you will use non-blocking millis() timing, a median filter, and the sense–think–act loop
  • An ESP32 dev board (with the ESP32 core installed in the Arduino IDE), a 2-motor chassis, an L298N/TB6612 driver, an HC-SR04 ultrasonic sensor, and a motor battery pack
Tools you will install
  • ESP32 dev board — a microcontroller with built-in Wi-Fi, so the rover can serve a control page with no extra hardware ↗
  • Arduino IDE with the ESP32 core — compile and upload ESP32 sketches, including the WiFi and WebServer libraries ↗
  • HC-SR04 ultrasonic sensor — measures the distance to whatever is ahead so the rover can avoid it ↗
  • L298N / TB6612 motor driver — drives the two DC motors from the battery under the ESP32's control ↗

Tick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.

Phase 1

Bring up the ESP32

Get the ESP32 toolchain working and understand its 3.3 V logic before wiring anything to it — the ESP32 is not 5 V-tolerant, and that catches people out.

  1. Install the ESP32 board support in the Arduino IDE, select your ESP32 board and port, and upload a Blink to confirm the upload path. ESP32 uploads sometimes need you to hold the BOOT button as it connects — learn your board's routine now.
    Check: The on-board LED blinks and the IDE reports a successful upload.
  2. Note the critical difference from an Uno: the ESP32's pins are 3.3 V logic and are NOT 5 V-tolerant. The HC-SR04's echo pin outputs 5 V, so plan a voltage divider (two resistors) on that one line to protect the board.
    Trigger can be driven at 3.3 V (the HC-SR04 accepts it). Only the ECHO line coming back needs dividing down to ~3.3 V before it reaches an ESP32 input pin.
  3. Write your pin plan into a comment block: two direction pins plus a PWM pin per motor, the ultrasonic trigger and (divided) echo pins, and note that on the ESP32 you drive PWM with ledcWrite channels rather than analogWrite. Planning avoids a rewire later.
    cpp
    // Pin plan (ESP32)
    // Left motor:  IN1=26, IN2=27, ENA=14 (PWM ch 0)
    // Right motor: IN3=25, IN4=33, ENB=32 (PWM ch 1)
    // Ultrasonic:  TRIG=5, ECHO=18 (through a divider to ~3.3V)
    // All grounds common with the motor battery.
  4. Prove the Wi-Fi radio works in isolation before it matters: upload a sketch that starts a soft access point and prints the IP, and confirm your phone can see the network. Verifying the ESP32's headline feature now means that when you add it late in the build, you already trust it.
    cpp
    #include <WiFi.h>
    void setup() {
      Serial.begin(115200);
      WiFi.softAP("rover-test", "test1234");
      Serial.println(WiFi.softAPIP());   // expect 192.168.4.1
    }
    void loop() {}
    Check: Your phone lists a 'rover-test' Wi-Fi network and the Serial Monitor prints an IP address.
Phase 2

Motors and a drive API

Get both motors driving from the ESP32 through the driver, using the ESP32's LEDC PWM, wrapped in a clean drive() you will reuse in both modes.

  1. Wire the driver and motors exactly as in the line follower — motors to outputs, battery to the driver's motor supply, and all grounds common with the ESP32 ground. Double-check the shared ground; a floating ground makes the whole thing behave randomly.
    As always: motors from their own battery through the driver, never from the ESP32's regulator.
  2. Set up two LEDC PWM channels for the enable pins and configure the direction pins as outputs. The ESP32 uses ledcSetup/ledcAttachPin/ledcWrite for PWM instead of analogWrite.
    cpp
    const int IN1=26, IN2=27, ENA=14;
    const int IN3=25, IN4=33, ENB=32;
    const int CH_L=0, CH_R=1;
    
    void setupMotors() {
      for (int p : {IN1, IN2, IN3, IN4}) pinMode(p, OUTPUT);
      ledcSetup(CH_L, 1000, 8);   // 1 kHz, 8-bit (0-255)
      ledcSetup(CH_R, 1000, 8);
      ledcAttachPin(ENA, CH_L);
      ledcAttachPin(ENB, CH_R);
    }
  3. Write the drive(left, right) API with signed speeds, setting direction pins and writing PWM to the channels. This mirrors the line follower's helper but uses LEDC — keep the same signature so your control code reads the same.
    cpp
    void motor(int in1, int in2, int ch, int speed) {
      bool fwd = speed >= 0;
      digitalWrite(in1, fwd ? HIGH : LOW);
      digitalWrite(in2, fwd ? LOW : HIGH);
      ledcWrite(ch, constrain(abs(speed), 0, 255));
    }
    
    void drive(int left, int right) {
      motor(IN1, IN2, CH_L, left);
      motor(IN3, IN4, CH_R, right);
    }
  4. Test forward, reverse and spin, exactly as before, and fix any reversed wheel. Confirm the rover drives predictably before you add sensing on top.
    Check: The rover drives forward, reverses, and spins in place on command with both wheels correct.
Phase 3

Sense what's ahead

Get a clean, trustworthy distance reading from the ultrasonic sensor — noisy distance readings are the number-one cause of a twitchy avoider.

  1. Wire the HC-SR04: VCC to 5 V, ground common, trigger to its pin, and echo through your voltage divider to the ESP32 echo pin. Confirm the divider outputs about 3.3 V, not 5 V, with a multimeter before connecting it to the board.
    Check: The echo line measures roughly 3.3 V (not 5 V) at the ESP32 pin when the sensor fires.
  2. Write a single-reading distance function using a trigger pulse and pulseIn, with a timeout so a missing echo does not hang the loop. Print it and wave your hand to confirm the numbers make sense.
    cpp
    const int TRIG=5, ECHO=18;
    
    long readCmOnce() {
      digitalWrite(TRIG, LOW); delayMicroseconds(2);
      digitalWrite(TRIG, HIGH); delayMicroseconds(10);
      digitalWrite(TRIG, LOW);
      long us = pulseIn(ECHO, HIGH, 25000);  // 25 ms timeout
      if (us == 0) return 400;               // no echo -> treat as 'far'
      return us / 58;                        // us to centimetres
    }
  3. Wrap it in a median-of-three filter so an occasional spurious reading cannot make the rover flinch. Taking the middle of three reads is a cheap, effective way to kill the isolated spikes ultrasonic sensors are prone to, straight from the Sensors lesson.
    cpp
    long readCm() {
      long a = readCmOnce(), b = readCmOnce(), c = readCmOnce();
      // median of three
      if (a > b) { long t=a; a=b; b=t; }
      if (b > c) { long t=b; b=c; c=t; }
      if (a > b) { long t=a; a=b; b=t; }
      return b;
    }
    Check: Waving a hand in and out gives smooth distance changes with no wild single-reading jumps.
  4. Read the distance on a non-blocking schedule with millis() rather than every loop, so the sensor's timing does not stall the rest of the rover. Store the latest distance in a variable the control code reads.
    cpp
    long lastDist = 400;
    unsigned long lastPing = 0;
    
    void updateDistance() {
      if (millis() - lastPing >= 60) {   // ~16 reads/sec, non-blocking
        lastDist = readCm();
        lastPing = millis();
      }
    }
Phase 4

Autonomous avoidance

Give the rover a behaviour of its own: roam forward, and when something is close, back off and turn to a clearer direction.

  1. Write the core avoid behaviour as a state you can reason about: if the way ahead is clear, drive forward; if an obstacle is within a threshold, stop, reverse briefly, then turn. Keep it non-blocking by tracking a state and a timer rather than using long delays.
    cpp
    enum Mode { ROAM, BACK, TURN };
    Mode mode = ROAM;
    unsigned long modeStart = 0;
    const int STOP_CM = 20;
    
    void setMode(Mode m) { mode = m; modeStart = millis(); }
    
    void avoidStep() {
      unsigned long t = millis() - modeStart;
      switch (mode) {
        case ROAM: drive(160, 160);
                   if (lastDist < STOP_CM) setMode(BACK);
                   break;
        case BACK: drive(-150, -150);
                   if (t > 350) setMode(TURN);
                   break;
        case TURN: drive(170, -170);           // spin to look for a gap
                   if (t > 450 && lastDist > STOP_CM + 10) setMode(ROAM);
                   break;
      }
    }
  2. In loop, call updateDistance() and avoidStep() every pass. Because both are non-blocking, the rover keeps sensing while it manoeuvres — the whole point of the millis() pattern.
    cpp
    void loop() {
      updateDistance();
      avoidStep();
    }
  3. Run the rover in a cluttered area. Tune STOP_CM (how close before it reacts) and the BACK/TURN durations until it moves confidently without either clipping obstacles or spinning forever looking for a gap.
    Check: The rover roams for a minute in a cluttered room without hitting anything, backing and turning away from obstacles.
  4. Add a hard safety floor: if the distance drops below a very small value at any time (about to touch), cut the motors immediately regardless of mode. A last-resort stop protects the rover and whatever it is about to hit.
    cpp
    void safety() {
      if (lastDist < 8) drive(0, 0);   // imminent contact: stop now
    }
    Call safety() after avoidStep() so it can override the behaviour. This same idea returns in manual mode, where it protects the rover from you.
Phase 5

Drive it from your phone

Serve a tiny control page from the ESP32 so you can steer the rover from any phone on the same Wi-Fi — turning the ESP32's headline feature into a real remote.

  1. Start the ESP32 as its own Wi-Fi access point so no router is needed — the rover creates a network your phone joins directly. Print the IP address to the Serial Monitor.
    cpp
    #include <WiFi.h>
    #include <WebServer.h>
    WebServer server(80);
    
    void setupWifi() {
      WiFi.softAP("rover-ai", "rover1234");   // network name + password
      Serial.println(WiFi.softAPIP());          // usually 192.168.4.1
    }
    Check: Your phone sees a 'rover-ai' network; joining it and visiting the printed IP loads a page.
  2. Register command endpoints the page will call — forward, back, left, right, stop — each setting a manual command the control loop will act on. Keep the handlers tiny: they only record intent, they do not drive directly.
    cpp
    volatile int cmdL = 0, cmdR = 0;
    unsigned long lastCmd = 0;
    
    void setCmd(int l, int r) { cmdL = l; cmdR = r; lastCmd = millis(); server.send(200, "text/plain", "ok"); }
    
    void setupRoutes() {
      server.on("/fwd",  [](){ setCmd( 180,  180); });
      server.on("/back", [](){ setCmd(-160, -160); });
      server.on("/left", [](){ setCmd(-150,  150); });
      server.on("/right",[](){ setCmd( 150, -150); });
      server.on("/stop", [](){ setCmd(0, 0); });
    }
  3. Serve a minimal HTML control page with five buttons that call those endpoints. Keep it simple — the goal is a working remote, not a pretty one. Register it on the root path.
    cpp
    const char* PAGE =
      "<!doctype html><meta name=viewport content='width=device-width'>"
      "<style>button{font-size:2rem;margin:6px;padding:18px}</style>"
      "<div style=text-align:center>"
      "<button onclick=f('/fwd')>Up</button><br>"
      "<button onclick=f('/left')>Left</button>"
      "<button onclick=f('/stop')>Stop</button>"
      "<button onclick=f('/right')>Right</button><br>"
      "<button onclick=f('/back')>Down</button>"
      "<script>function f(u){fetch(u)}</script></div>";
    
    void setupRoot() { server.on("/", [](){ server.send(200, "text/html", PAGE); }); }
  4. Call server.handleClient() every loop and drive the motors from the latest command. Add a command timeout: if no command arrives for a second (you walked out of range or the tab closed), stop — a rover that keeps its last command forever is a runaway.
    cpp
    void manualStep() {
      server.handleClient();
      if (millis() - lastCmd > 1000) { cmdL = 0; cmdR = 0; }  // dead-man timeout
      drive(cmdL, cmdR);
    }
    Check: Tapping the phone buttons moves the rover, and it stops on its own about a second after you stop tapping.
Phase 6

Two modes, safely combined

Bring autonomous and manual together into one rover with a mode switch, where the obstacle safety still protects it even when you are driving.

  1. Add a mode flag (autonomous vs manual) and a /mode endpoint or a physical button that toggles it. In loop, run the avoidance behaviour or the manual driver depending on the flag — but always call updateDistance() and safety() so sensing never stops.
    cpp
    bool autonomous = true;
    
    void loop() {
      updateDistance();
      server.handleClient();
      if (autonomous) avoidStep();
      else            manualStep();
      safety();               // overrides both when about to hit something
    }
  2. Test the safety override in manual mode: deliberately drive the rover toward a wall from your phone and confirm the safety floor stops it before contact, even though you are commanding forward. Sensing that protects the human driver is what makes the rover trustworthy.
    Check: Driving manually straight at a wall, the rover halts itself before touching it.
  3. Add a status line to the web page (or the Serial Monitor) showing the current mode and the latest distance, so you can see what the rover senses while you drive. Visibility into its state makes tuning and debugging far quicker.
    You can serve a /status endpoint returning the distance and mode as text, and have the page poll it every half second — reusing the exact idea from the line follower's telemetry extension.
  4. Run a full session: let it roam autonomously, switch to manual and drive it back, and confirm it never hits anything in either mode over several minutes. A rover that is safe in both modes, for minutes at a time, is the finished project.
    Check: Over a multi-minute session the rover avoids obstacles autonomously and is drivable manually, with no collisions in either mode.
  5. Record your tuned constants — STOP_CM, the BACK/TURN durations, the safety floor, the PWM speeds and the command timeout — in the sketch header, and note the battery you tuned against. As with the line follower, these numbers are the rover's behaviour, and a fresh battery or a bigger room will shift them; keep the record so you re-tune from it, not from scratch.
    A rover feels very different on a full versus a half battery, because motor speed follows voltage. If it starts clipping obstacles after a while, suspect the battery before the code.
Help

Troubleshooting

The ESP32 resets or crashes as soon as the motors run.
The motor supply is browning out the board, or a 5 V line is feeding a 3.3 V pin. Confirm the motors run from their own battery through the driver with a common ground, that the ESP32 is powered from a stable source, and that the HC-SR04 echo goes through a divider — a raw 5 V echo into a GPIO can crash or damage the ESP32.
Distance readings are wildly noisy or often read 0 or 400.
A 0 means no echo returned (out of range, or the pulse missed); the code already maps that to 'far'. For jitter, confirm the median filter is in use, add the ~60 ms spacing between reads so echoes do not overlap, and make sure the sensor is not aimed at a soft or angled surface that scatters the echo.
The web page loads but the buttons do nothing.
Your phone may have dropped off the rover's access point onto mobile data, or the endpoint paths do not match. Confirm the phone is still on the 'rover-ai' network, check the Serial Monitor for incoming requests, and verify the fetch() URLs in the page exactly match the server.on() paths.
The rover keeps driving after you stop tapping, or after the phone locks.
The dead-man timeout is missing or too long. Ensure manualStep() zeroes the command when millis()-lastCmd exceeds your timeout, so a lost connection or a locked phone stops the rover instead of leaving it on its last command.
In autonomous mode the rover spins in place forever.
Its TURN state never finds a clear direction because STOP_CM is too large for the space or the turn is too short to sweep past the obstacle. Lower STOP_CM, lengthen the TURN duration, or require a larger clear margin before returning to ROAM so it commits to a genuinely open direction.
Next

Where to go from here

Did a step fail or feel unclear? Tell me which one →