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.
- 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
- 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.
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.
- 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.
- 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.
- 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
ledcWritechannels rather thananalogWrite. 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. - 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.
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.
- 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.
- Set up two LEDC PWM channels for the enable pins and configure the direction pins as outputs. The ESP32 uses
ledcSetup/ledcAttachPin/ledcWritefor PWM instead ofanalogWrite.cppconst 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); } - 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.cppvoid 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); } - 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.
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.
- 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.
- 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.cppconst 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 } - 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. - 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.cpplong lastDist = 400; unsigned long lastPing = 0; void updateDistance() { if (millis() - lastPing >= 60) { // ~16 reads/sec, non-blocking lastDist = readCm(); lastPing = millis(); } }
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.
- 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; } } - In
loop, callupdateDistance()andavoidStep()every pass. Because both are non-blocking, the rover keeps sensing while it manoeuvres — the whole point of the millis() pattern.cppvoid loop() { updateDistance(); avoidStep(); } - 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.
- 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.
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.
- 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. - 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); }); } - 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); }); } - 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.cppvoid 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.
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.
- Add a mode flag (autonomous vs manual) and a
/modeendpoint or a physical button that toggles it. Inloop, run the avoidance behaviour or the manual driver depending on the flag — but always callupdateDistance()andsafety()so sensing never stops.cppbool autonomous = true; void loop() { updateDistance(); server.handleClient(); if (autonomous) avoidStep(); else manualStep(); safety(); // overrides both when about to hit something } - 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.
- 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.
- 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.
- 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.
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.
Where to go from here
- Mount the ultrasonic sensor on a servo that sweeps left and right, so the rover measures several directions and turns toward the most open one instead of guessing.
- Add wheel encoders and the odometry from the Kinematics lesson, so the rover can estimate how far it has travelled and drive a set distance or return roughly to its start.
- Replace the softAP with joining your home Wi-Fi and serving a richer dashboard that live-plots the distance, giving you real telemetry like the interview-grade projects in other tracks.
- Swap the ultrasonic sensor for a small time-of-flight or a simple camera on a Raspberry Pi and bring in the Vision lesson to follow a coloured target instead of merely avoiding walls.
Did a step fail or feel unclear? Tell me which one →