Community project

Phone-Controlled Forward Car

ESP32
Photo of Phone-Controlled Forward Car
Generated with AI

Kuan.

Published September 20, 2026

This project builds a WiFi-controlled wheeled robot powered by an ESP32 that streams live video and responds to phone commands. The rover uses dual 12 V gear motors with encoders for precise speed matching, dual motor drivers for independent wheel control, and a protected lithium-ion battery supply with a buck converter for clean 5 V logic power.

The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for integrating the motor drivers, encoders, and power distribution. Included firmware implements closed-loop speed control, a web interface for phone control, and live camera streaming—allowing the rover to drive forward, reverse, turn left and right from any browser on the local WiFi network.

Wiring diagram

Wiring diagram for Phone-Controlled Forward Car

Gather all the parts

QtyComponent
1

DRV8871 single-channel motor-driver module (left motor)

6.5–45 V motor supply

A ready-made H-bridge board that lets the controller vary the left motor's direction and speed.

1

DRV8871 single-channel motor-driver module (right motor)

6.5–45 V motor supply

A ready-made H-bridge board that lets the controller vary the right motor's direction and speed.

1

JGB37-520 12 V gear motor with quadrature encoder (left)

12 V

The left 12 V geared wheel motor with a built-in encoder that reports how far and how fast it has turned.

1

JGB37-520 12 V gear motor with quadrature encoder (right)

12 V

The right 12 V geared wheel motor with a built-in encoder that reports how far and how fast it has turned.

1

MP1584 Buck Converter

Set to 5.0 V

Adjustable buck (step-down) DC-DC converter, 4.5-28 V in -> 0.8-20 V out, ~3 A. Configured to 5 V to step a 9 V / 12 V supply or battery pack down to the board's 5 V rail.

1

3S 18650 lithium-ion battery pack with balance leads

3S, 11.1 V nominal

Three 18650 lithium-ion cells connected in series, with two middle-cell tap wires for the protection board.

1

3S lithium-ion BMS, 10 A or higher

3S, ≥10 A continuous

A battery protection board that prevents unsafe overcharge, over-discharge, and excessive current from the 3-cell pack.

Assemble it in 5 steps

1. Build the protected 3-cell battery supply

Use a properly matched 3-cell 18650 battery pack and its 3S BMS. Connect battery PACK− to BMS B−, the first cell join to B1, the second cell join to B2, and battery PACK+ to B+. Use BMS P+ and P− as the car’s only power output (protected battery power).

  • Use 16–18 AWG wire for the BMS output and motor supply wires because the motors can draw a large starting current.
  • Never reverse the B1 and B2 cell-tap wires — a wrongly connected BMS can be destroyed immediately.
  • Never connect a charger or motor load directly to bare cells; bypassing the BMS can overheat or permanently damage lithium-ion cells.

2. Set and connect the 5 V converter

Connect MP1584 VIN to BMS P+ (battery power) and MP1584 GND to BMS P− (ground). Before connecting the camera board, measure MP1584 VOUT with a multimeter and adjust its screw to exactly 5.0 V. Connect VOUT to the camera board 5V/VIN pin (controller power) and connect MP1584 GND to a camera-board GND pin (shared ground).

  • Mark the MP1584 module “5 V” after adjusting it so it cannot be accidentally changed later.
  • Do not connect the 3-cell battery directly to the camera board — full battery voltage can damage it.
  • Do not connect the converter to the camera board until its output measures exactly 5.0 V — excessive voltage can damage the board.

3. Wire both motor drivers

For the left driver, connect VM to BMS P+ (motor power), GND to BMS P− (ground), IN1 to GPIO38 (left motor control), and IN2 to GPIO39 (left motor control). Connect OUT1 to the left motor M+ (motor output) and OUT2 to left motor M− (motor output). For the right driver, connect VM to BMS P+ (motor power), GND to BMS P− (ground), IN1 to GPIO40 (right motor control), and IN2 to GPIO41 (right motor control). Connect OUT1 to the right motor M+ (motor output) and OUT2 to right motor M− (motor output).

  • Keep the thick motor wires short and routed away from the camera ribbon cable.
  • If Forward makes one wheel turn the wrong way, disconnect battery power and swap only that motor’s two thick wires at its driver output.
  • All driver GND terminals must reach BMS P− and the camera-board GND; without this shared ground, motor-control signals are unreliable.
  • Do not change motor wires while the battery is connected — a short circuit can damage the driver or battery wiring.

4. Connect the wheel encoders

Connect each encoder VCC wire to the camera board 3V3 pin (safe encoder power) and each encoder GND wire to camera-board GND (ground). Connect left encoder A to GPIO42 (left wheel pulses) and right encoder A to GPIO47 (right wheel pulses). Leave each encoder B wire separately covered with heat-shrink or tape (unused signal).

  • Your motor cable colours may differ; identify the encoder’s VCC, GND, A, and B wires from the motor seller’s diagram before connecting them.
  • The encoder must be powered from 3.3 V, not the 5 V converter output, so its pulse signal cannot damage the ESP32 input.
  • Do not connect an encoder output that measures 5 V to GPIO42 or GPIO47 — 5 V signals can damage the ESP32. Use the encoder's 3.3 V supply and check its signal level first.
  • Keep encoder wires away from the motor wires where possible; motor noise can create false wheel pulses.

5. Mount, deploy, and drive from the phone

Secure the battery, BMS, converter, motor drivers, ESP32 board, and OV2640 camera so no metal pads can touch. Keep the wheels lifted for the first test. Plug the board into USB and press Deploy. On the phone, join Wi-Fi network Rover-Cam using password drive-rover, then open http://192.168.4.1. The page shows the live camera image and driving buttons. Hold Forward or Reverse to drive straight; use Left or Right while moving forward for a gentle rolling turn.

  • The car stops if the phone stops sending commands for about half a second.
  • Turning slows the inside wheel instead of reversing it, which reduces tyre scrub and sliding.
  • For the first test, keep the wheels off the ground and clear of fingers, hair, clothing, and camera wires.
  • Disconnect battery power before altering any wiring.

Review all connections

1. Connections between "battery_3s" and "ESP32"

Functionbattery_3sESP32
powerPACK+3S lithium-ion BMS, 10 A or higher B+EXT
groundPACK-3S lithium-ion BMS, 10 A or higher B-EXT
powerCELL1 TAP3S lithium-ion BMS, 10 A or higher B1EXT
powerCELL2 TAP3S lithium-ion BMS, 10 A or higher B2EXT

2. Connections between "bms_3s" and "ESP32"

Functionbms_3sESP32
powerP+DRV8871 single-channel motor-driver module (left motor) VMEXT
powerP+DRV8871 single-channel motor-driver module (right motor) VMEXT
powerP+MP1584 Buck Converter VINEXT
groundP-DRV8871 single-channel motor-driver module (left motor) GNDEXT
groundP-DRV8871 single-channel motor-driver module (right motor) GNDEXT
groundP-MP1584 Buck Converter GNDEXT

3. Connections between "drv8871_left" and "ESP32"

Functiondrv8871_leftESP32
powerOUT1JGB37-520 12 V gear motor with quadrature encoder (left) M+EXT
powerOUT2JGB37-520 12 V gear motor with quadrature encoder (left) M-EXT
pwmIN1GPIO 38
pwmIN2GPIO 39

4. Connections between "drv8871_right" and "ESP32"

Functiondrv8871_rightESP32
powerOUT1JGB37-520 12 V gear motor with quadrature encoder (right) M+EXT
powerOUT2JGB37-520 12 V gear motor with quadrature encoder (right) M-EXT
pwmIN1GPIO 40
pwmIN2GPIO 41

5. Connections between "left_motor" and "ESP32"

Functionleft_motorESP32
powerEncoder VCC3V3
groundEncoder GNDGND
digitalEncoder AGPIO 42

6. Connections between "right_motor" and "ESP32"

Functionright_motorESP32
powerEncoder VCC3V3
groundEncoder GNDGND
digitalEncoder AGPIO 47

7. Connections between "mp1584_5v" and "ESP32"

Functionmp1584_5vESP32
powerVOUTVIN
groundGNDGND

Deploy the firmware

#include <Arduino.h>
#include <WiFi.h>
#include "esp_camera.h"
#include "esp_http_server.h"

enum Motion { STOPPED, FORWARD, REVERSE, LEFT, RIGHT };


// Forward declarations
void IRAM_ATTR leftEncoderISR();
void IRAM_ATTR rightEncoderISR();
void writeMotor(int in1, int in2, int speed);
void setMotorOutputs(int leftSpeed, int rightSpeed);
bool reversesWheelDirection(Motion next);
void setRequestedMotion(Motion next);
Motion motionFromText(const String &text);
void updateClosedLoopDrive();
esp_err_t indexHandler(httpd_req_t *request);
esp_err_t moveHandler(httpd_req_t *request);
esp_err_t streamHandler(httpd_req_t *request);
void startServers();
bool startCamera();

constexpr int LEFT_IN1 = 38;
constexpr int LEFT_IN2 = 39;
constexpr int RIGHT_IN1 = 40;
constexpr int RIGHT_IN2 = 41;
constexpr int LEFT_ENCODER_A = 42;
constexpr int RIGHT_ENCODER_A = 47;
constexpr uint32_t COMMAND_TIMEOUT_MS = 450;
constexpr uint32_t SPEED_SAMPLE_MS = 100;
constexpr uint32_t DIRECTION_CHANGE_PAUSE_MS = 120;
constexpr uint32_t PWM_FREQUENCY = 18000;
constexpr uint8_t PWM_RESOLUTION = 8;
constexpr int PWM_MAX = 255;
constexpr int DRIVE_SPEED = 180;
constexpr int TURN_INNER_SPEED = 100;
constexpr float SPEED_MATCH_GAIN = 1.8f;
constexpr int MAX_SPEED_CORRECTION = 40;

const char *AP_NAME = "Rover-Cam";
const char *AP_PASSWORD = "drive-rover";

httpd_handle_t webServer = nullptr;
httpd_handle_t streamServer = nullptr;
volatile uint32_t leftEncoderTicks = 0;
volatile uint32_t rightEncoderTicks = 0;
uint32_t previousLeftTicks = 0;
uint32_t previousRightTicks = 0;
uint32_t lastSpeedSampleMs = 0;
uint32_t lastCommandMs = 0;
Motion currentMotion = STOPPED;
Motion requestedMotion = STOPPED;
int leftCommand = 0;
int rightCommand = 0;

void IRAM_ATTR leftEncoderISR() { leftEncoderTicks++; }
void IRAM_ATTR rightEncoderISR() { rightEncoderTicks++; }

void writeMotor(int in1, int in2, int speed) {
  int duty = constrain(abs(speed), 0, PWM_MAX);
  if (speed > 0) {
    ledcWrite(in1, duty);
    ledcWrite(in2, 0);
  } else if (speed < 0) {
    ledcWrite(in1, 0);
    ledcWrite(in2, duty);
  } else {
    ledcWrite(in1, 0);
    ledcWrite(in2, 0);
  }
}

void setMotorOutputs(int leftSpeed, int rightSpeed) {
  writeMotor(LEFT_IN1, LEFT_IN2, leftSpeed);
  writeMotor(RIGHT_IN1, RIGHT_IN2, rightSpeed);
}

bool reversesWheelDirection(Motion next) {
  if (currentMotion == STOPPED || next == STOPPED) return false;
  if ((currentMotion == FORWARD && next == REVERSE) || (currentMotion == REVERSE && next == FORWARD)) return true;
  return false;
}

void setRequestedMotion(Motion next) {
  if (reversesWheelDirection(next)) {
    setMotorOutputs(0, 0);
    delay(DIRECTION_CHANGE_PAUSE_MS);
  }
  requestedMotion = next;
  currentMotion = next;
  if (next == STOPPED) {
    leftCommand = 0;
    rightCommand = 0;
    setMotorOutputs(0, 0);
  }
}

Motion motionFromText(const String &text) {
  if (text == "forward") return FORWARD;
  if (text == "reverse") return REVERSE;
  if (text == "left") return LEFT;
  if (text == "right") return RIGHT;
  return STOPPED;
}

void updateClosedLoopDrive() {
  uint32_t now = millis();
  if (now - lastSpeedSampleMs < SPEED_SAMPLE_MS) return;
  lastSpeedSampleMs = now;
  noInterrupts();
  uint32_t leftNow = leftEncoderTicks;
  uint32_t rightNow = rightEncoderTicks;
  interrupts();
  int leftTicks = leftNow - previousLeftTicks;
  int rightTicks = rightNow - previousRightTicks;
  previousLeftTicks = leftNow;
  previousRightTicks = rightNow;

  if (requestedMotion == STOPPED) return;
  int leftBase = DRIVE_SPEED;
  int rightBase = DRIVE_SPEED;
  int direction = requestedMotion == REVERSE ? -1 : 1;
  if (requestedMotion == LEFT) leftBase = TURN_INNER_SPEED;
  if (requestedMotion == RIGHT) rightBase = TURN_INNER_SPEED;

  // The faster wheel is gently reduced and the slower wheel raised, so straight driving stays straight.
  int correction = constrain((leftTicks - rightTicks) * SPEED_MATCH_GAIN, -MAX_SPEED_CORRECTION, MAX_SPEED_CORRECTION);
  leftCommand = constrain(leftBase - correction, 0, PWM_MAX);
  rightCommand = constrain(rightBase + correction, 0, PWM_MAX);
  setMotorOutputs(direction * leftCommand, direction * rightCommand);
}

const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no"><title>Rover Cam</title><style>
body{margin:0;background:#111;color:#eee;font:18px Arial;text-align:center}h1{font-size:22px;margin:12px}img{width:100%;max-width:640px;background:#222}.pad{display:grid;grid-template-columns:repeat(3,88px);grid-template-rows:repeat(3,62px);gap:8px;justify-content:center;margin:15px auto}button{font-size:18px;border:0;border-radius:10px;background:#2676d2;color:white;touch-action:none}button.stop{background:#c62828}.note{font-size:13px;color:#bbb;margin:10px}
</style></head><body><h1>Rover Cam</h1><img src="http://192.168.4.1:81/stream" alt="live camera stream"><div class="pad"><span></span><button data-c="forward">▲</button><span></span><button data-c="left">◀</button><button class="stop" data-c="stop">STOP</button><button data-c="right">▶</button><span></span><button data-c="reverse">▼</button><span></span></div><div class="note">Hold a direction button to move. Releasing it stops the car.</div><script>
let held=false,active='stop';function send(c){fetch('/move?d='+c).catch(()=>{});}document.querySelectorAll('button').forEach(b=>{let c=b.dataset.c;b.onpointerdown=e=>{e.preventDefault();held=true;active=c;send(c)};b.onpointerup=b.onpointerleave=b.onpointercancel=e=>{if(held){held=false;active='stop';send('stop')}};b.onclick=()=>{if(c==='stop')send('stop')}});setInterval(()=>{if(held)send(active)},180);
</script></body></html>
)HTML";

esp_err_t indexHandler(httpd_req_t *request) {
  httpd_resp_set_type(request, "text/html");
  return httpd_resp_send(request, INDEX_HTML, HTTPD_RESP_USE_STRLEN);
}

esp_err_t moveHandler(httpd_req_t *request) {
  char query[32] = {};
  char command[12] = {};
  if (httpd_req_get_url_query_str(request, query, sizeof(query)) == ESP_OK) httpd_query_key_value(query, "d", command, sizeof(command));
  setRequestedMotion(motionFromText(String(command)));
  lastCommandMs = millis();
  httpd_resp_set_type(request, "text/plain");
  return httpd_resp_sendstr(request, "OK");
}

esp_err_t streamHandler(httpd_req_t *request) {
  static const char *CONTENT_TYPE = "multipart/x-mixed-replace;boundary=frame";
  static const char *BOUNDARY = "\r\n--frame\r\n";
  static const char *PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";
  char header[64];
  httpd_resp_set_type(request, CONTENT_TYPE);
  while (true) {
    camera_fb_t *frame = esp_camera_fb_get();
    if (!frame) return ESP_FAIL;
    esp_err_t result = httpd_resp_send_chunk(request, BOUNDARY, strlen(BOUNDARY));
    if (result == ESP_OK) result = httpd_resp_send_chunk(request, header, snprintf(header, sizeof(header), PART, frame->len));
    if (result == ESP_OK) result = httpd_resp_send_chunk(request, reinterpret_cast<const char *>(frame->buf), frame->len);
    esp_camera_fb_return(frame);
    if (result != ESP_OK) return result;
  }
}

void startServers() {
  httpd_config_t config = HTTPD_DEFAULT_CONFIG();
  config.max_uri_handlers = 4;
  httpd_uri_t indexUri = {.uri = "/", .method = HTTP_GET, .handler = indexHandler, .user_ctx = nullptr};
  httpd_uri_t moveUri = {.uri = "/move", .method = HTTP_GET, .handler = moveHandler, .user_ctx = nullptr};
  if (httpd_start(&webServer, &config) == ESP_OK) { httpd_register_uri_handler(webServer, &indexUri); httpd_register_uri_handler(webServer, &moveUri); }
  config.server_port = 81; config.ctrl_port = 32769;
  httpd_uri_t streamUri = {.uri = "/stream", .method = HTTP_GET, .handler = streamHandler, .user_ctx = nullptr};
  if (httpd_start(&streamServer, &config) == ESP_OK) httpd_register_uri_handler(streamServer, &streamUri);
}

bool startCamera() {
  camera_config_t config = {};
  config.ledc_channel = LEDC_CHANNEL_0; config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = 11; config.pin_d1 = 9; config.pin_d2 = 8; config.pin_d3 = 10;
  config.pin_d4 = 12; config.pin_d5 = 18; config.pin_d6 = 17; config.pin_d7 = 16;
  config.pin_xclk = 15; config.pin_pclk = 13; config.pin_vsync = 6; config.pin_href = 7;
  config.pin_sccb_sda = 4; config.pin_sccb_scl = 5; config.pin_pwdn = -1; config.pin_reset = -1;
  config.xclk_freq_hz = 20000000; config.pixel_format = PIXFORMAT_JPEG; config.frame_size = FRAMESIZE_QVGA;
  config.jpeg_quality = 12; config.fb_count = psramFound() ? 2 : 1;
  config.fb_location = psramFound() ? CAMERA_FB_IN_PSRAM : CAMERA_FB_IN_DRAM; config.grab_mode = CAMERA_GRAB_LATEST;
  return esp_camera_init(&config) == ESP_OK;
}

void setup() {
  Serial.begin(115200);
  ledcAttach(LEFT_IN1, PWM_FREQUENCY, PWM_RESOLUTION); ledcAttach(LEFT_IN2, PWM_FREQUENCY, PWM_RESOLUTION);
  ledcAttach(RIGHT_IN1, PWM_FREQUENCY, PWM_RESOLUTION); ledcAttach(RIGHT_IN2, PWM_FREQUENCY, PWM_RESOLUTION);
  setMotorOutputs(0, 0);
  pinMode(LEFT_ENCODER_A, INPUT_PULLUP); pinMode(RIGHT_ENCODER_A, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(LEFT_ENCODER_A), leftEncoderISR, RISING);
  attachInterrupt(digitalPinToInterrupt(RIGHT_ENCODER_A), rightEncoderISR, RISING);
  if (!startCamera()) { Serial.println("Camera setup failed; check the OV2640 ribbon cable."); return; }
  WiFi.mode(WIFI_AP); WiFi.softAP(AP_NAME, AP_PASSWORD);
  Serial.print("Connect phone to Rover-Cam, then open http://"); Serial.println(WiFi.softAPIP());
  startServers(); lastCommandMs = millis();
}

void loop() {
  if (currentMotion != STOPPED && millis() - lastCommandMs > COMMAND_TIMEOUT_MS) setRequestedMotion(STOPPED);
  updateClosedLoopDrive();
  delay(2);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik