Tutorial · Intermediate · 1.5 hours

Drive a Robot From a Browser With an ESP32 Web Server

Turn an ESP32 into a Wi-Fi access point serving its own control page, so any phone can drive your robot from a browser with no app to install.

Published

Introduction

An HC-05 needs a serial terminal app and a pairing dance. An ESP32 can do something better: be its own network and serve its own control page. You connect your phone to the robot’s Wi-Fi, open a browser, and drive. Nothing to install, and it works from any device with a browser.

The ESP32 has Wi-Fi and Bluetooth on the die, two cores at 240 MHz, and enough RAM to hold a web page in flash and serve it. That is the whole trick.

Access point or existing network?

Two modes, and the choice matters more than it looks.

Access point (AP) — the ESP32 creates its own Wi-Fi network and the phone joins it.

  • Works anywhere, with no router. The robot is self-contained.
  • The phone loses its internet connection while driving, and some phones will silently switch back to mobile data and drop you.
  • The address is always the same, typically 192.168.4.1.

Station (STA) — the ESP32 joins your existing Wi-Fi.

  • The phone keeps its internet connection.
  • The robot’s IP is assigned by the router and can change between boots.
  • Useless outside range of that one network, which rules out demos.

For a robot you carry around, AP mode is almost always right:

#include <WiFi.h>
#include <WebServer.h>

WebServer server(80);

void setup() {
  WiFi.softAP("RobotCar", "drive1234");   // password must be 8+ characters
  Serial.println(WiFi.softAPIP());        // 192.168.4.1
  server.on("/", handleRoot);
  server.on("/cmd", handleCommand);
  server.begin();
}

void loop() {
  server.handleClient();
  applyDeadman();                          // see below — this is not optional
}

Serving the control page

The page lives in flash as a raw string. Keep it small — every byte is served from a microcontroller — and make the controls touch friendly, because the entire point is driving from a phone.

const char PAGE[] PROGMEM = R"(
<!doctype html><meta name=viewport content="width=device-width,initial-scale=1">
<style>
  body{font-family:system-ui;margin:0;display:grid;place-items:center;height:100vh}
  button{width:90px;height:90px;font-size:24px;margin:4px;touch-action:manipulation}
</style>
<div>
  <div style="text-align:center"><button data-c="F">▲</button></div>
  <div><button data-c="L">◀</button><button data-c="S">■</button><button data-c="R">▶</button></div>
  <div style="text-align:center"><button data-c="B">▼</button></div>
</div>
<script>
let held = null;
const send = c => fetch('/cmd?c=' + c);
for (const b of document.querySelectorAll('button')) {
  // Repeat while held, so releasing and losing signal look the same to the robot.
  b.onpointerdown = e => { held = b.dataset.c; send(held);
                           b.setPointerCapture(e.pointerId); };
  b.onpointerup = b.onpointercancel = () => { held = null; send('S'); };
}
setInterval(() => held && send(held), 100);
</script>
)";

void handleRoot() { server.send_P(200, "text/html", PAGE); }

touch-action: manipulation on the buttons matters: without it, mobile browsers wait ~300 ms to see whether you meant a double-tap, and the robot feels broken.

The deadman timeout, again

Everything the Bluetooth tutorial says about losing the link applies here, and more so — Wi-Fi range ends abruptly rather than gracefully.

unsigned long lastCommandAt = 0;

void handleCommand() {
  char c = server.arg("c")[0];
  drive(c);
  lastCommandAt = millis();
  server.send(200, "text/plain", "ok");
}

void applyDeadman() {
  if (millis() - lastCommandAt > 400) stop();
}

The browser sends the held command every 100 ms, so a 400 ms timeout tolerates three dropped requests before stopping. That ratio is the thing to tune: too tight and the robot stutters on a weak link, too loose and it keeps driving after you have lost it.

When to move to WebSockets

The fetch approach above opens a fresh HTTP connection for every command. It is simple, it is easy to debug in the browser’s network tab, and at 10 commands a second it is fine.

It stops being fine when you want telemetry back. Polling for sensor values with more HTTP requests doubles the traffic and adds latency to both directions. A WebSocket holds one connection open and lets either side speak at any time:

#include <WebSocketsServer.h>
WebSocketsServer ws(81);

void onWsEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t len) {
  if (type == WStype_TEXT) { drive(payload[0]); lastCommandAt = millis(); }
  if (type == WStype_DISCONNECTED) stop();   // the link closing IS a stop command
}

That last line is the real upgrade. With HTTP the robot can only infer a lost connection from silence; a WebSocket tells you the moment it closes, so the robot stops immediately instead of after a timeout.

Start with HTTP. Move to WebSockets when you want the robot talking back.

Powering it

The ESP32 is a 3.3 V part and draws current in sharp bursts when the radio transmits — well over 200 mA at the peak. Two consequences:

Do not power it from the Arduino’s 3.3 V pin. That regulator cannot supply the transmit peaks, and the symptom is a board that reboots whenever the Wi-Fi gets busy, which looks like a code bug and is not one.

Its GPIO pins are not 5 V tolerant. Anything feeding a signal into the ESP32 from 5 V logic needs a divider or a level shifter, the same as the HC-05’s RX line.

Give it a proper supply from the battery pack through its own regulator, with the motor supply kept separate.

Verify before you drive

  1. Serve the page. Connect a phone to RobotCar, open http://192.168.4.1, and confirm the buttons appear.
  2. Watch the commands. Print each received command over USB. Hold a button and confirm the repeats arrive at roughly 10 per second.
  3. Kill the link. Hold forward, then switch your phone’s Wi-Fi off. The robot must stop within the timeout.

Then put it in a robot: the phone-controlled robot project builds the whole thing, either radio, on a chassis that can also drive itself.

Explore the graph

Part of these builds

Projects and learning paths that include this tutorial.

Further reading

References