ESP32-CAM Surveillance Car with Live Video Streaming and Wireless Control

In this project, you’ll learn how to build a Wi-Fi-controlled surveillance car using the ESP32-CAM module. It streams live grayscale video and can be controlled remotely from any smartphone or computer browser — no apps required!


Features

  • Live grayscale video stream over Wi-Fi
  • Web-based motor control interface (Forward, Backward, Left, Right, Stop)
  • Built with ESP32-CAM (low-cost, compact module)
  • Uses L298N or HW-130 motor driver
  • Control and view from any device on the same network

Components Required

ComponentQuantity
ESP32-CAM module (AI Thinker)1
FTDI USB-to-Serial Adapter1
L298N or HW-130 Motor Driver1
DC Geared Motors + Wheels2
Chassis + Battery Holder1
18650 Battery or 7.4V LiPo1
Jumper WiresSeveral
Optional: LEDs or buzzerOptional

Circuit Diagram & Connections

Power:

  • ESP32-CAM powered via FTDI (for programming) and 5V regulator or battery
  • Motor Driver powered by separate battery (6–12V)

ESP32-CAM Camera Pins: (use as-is)

Motor Driver Wiring (L298N or HW-130)

ESP32-CAM GPIOMotor Driver
GPIO 12IN1
GPIO 13IN2
GPIO 14IN3
GPIO 15IN4
5VENA (via jumper)
5VENB (via jumper)
GNDGND (shared with motor GND)

Don’t forget: Place jumper caps on ENA and ENB if not using PWM.


Software Setup

1. Install ESP32 Board Support in Arduino IDE

  • Go to File > Preferences → Paste these URLs in “Additional Board URLs”:
https://dl.espressif.com/dl/package_esp32_index.json
https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json
  • Go to Tools > Board > Board Manager → Search “ESP32” → Install

2. Select:

  • Board: AI Thinker ESP32-CAM
  • Partition Scheme: Default 4MB with SPIFFS
  • PSRAM: Enabled (if available)

Code:

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

// Wi-Fi credentials
const char* ssid = "YOUR_SSID";
const char* password = "YOUR_PASSWORD";

// Motor driver pins (L298N / HW-130)
#define IN1 12
#define IN2 13
#define IN3 14
#define IN4 15

WebServer controlServer(80);
WiFiServer streamServer(81);

// ESP32-CAM AI Thinker pin config
#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM     0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27
#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM       5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

// ===== Motor Control =====
void stopMotors() {
  digitalWrite(IN1, LOW); digitalWrite(IN2, LOW);
  digitalWrite(IN3, LOW); digitalWrite(IN4, LOW);
}

void moveForward() {
  digitalWrite(IN1, HIGH); digitalWrite(IN2, LOW);
  digitalWrite(IN3, HIGH); digitalWrite(IN4, LOW);
}

void moveBackward() {
  digitalWrite(IN1, LOW); digitalWrite(IN2, HIGH);
  digitalWrite(IN3, LOW); digitalWrite(IN4, HIGH);
}

void turnLeft() {
  digitalWrite(IN1, LOW); digitalWrite(IN2, HIGH);
  digitalWrite(IN3, HIGH); digitalWrite(IN4, LOW);
}

void turnRight() {
  digitalWrite(IN1, HIGH); digitalWrite(IN2, LOW);
  digitalWrite(IN3, LOW); digitalWrite(IN4, HIGH);
}

// ===== Web Control UI =====
void handleRoot() {
  controlServer.send(200, "text/html", R"rawliteral(
    <html><head>
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>ESP32 Surveillance Car</title>
      <style>
        button { width: 80px; height: 50px; font-size: 16px; margin: 5px; }
        .btns { display: flex; flex-wrap: wrap; justify-content: center; }
        img { width: 100%; max-width: 480px; }
      </style>
    </head><body>
      <h2 align="center">ESP32 Surveillance Car</h2>
      <div align="center">
        <img src="http://)rawliteral" + WiFi.localIP().toString() + R"rawliteral(:81/stream">
      </div>
      <div class="btns">
        <button onclick="send('F')">Forward</button><br>
        <button onclick="send('L')">Left</button>
        <button onclick="send('S')">Stop</button>
        <button onclick="send('R')">Right</button><br>
        <button onclick="send('B')">Backward</button>
      </div>
      <script>
        function send(cmd) {
          fetch("/control?move=" + cmd);
        }
      </script>
    </body></html>
  )rawliteral");
}

// ===== Command Handler =====
void handleControl() {
  String move = controlServer.arg("move");
  Serial.println("Received command: " + move);

  if (move == "F") moveForward();
  else if (move == "B") moveBackward();
  else if (move == "L") turnLeft();
  else if (move == "R") turnRight();
  else stopMotors();

  controlServer.send(204, "", "");
}

// ===== Camera Setup (Grayscale) =====
void startCamera() {
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM; config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM; config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM; config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM; config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href  = HREF_GPIO_NUM;
  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;

  config.pixel_format = PIXFORMAT_GRAYSCALE;
  config.frame_size = FRAMESIZE_CIF; // 352x288
  config.jpeg_quality = 12;
  config.fb_count = 1;

  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("Camera init failed: 0x%x\n", err);
    while (true);
  }
}

// ===== Streaming Task =====
void streamVideo() {
  WiFiClient client = streamServer.available();
  if (!client) return;

  client.println("HTTP/1.1 200 OK");
  client.println("Content-Type: multipart/x-mixed-replace; boundary=frame");
  client.println();

  while (client.connected()) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) continue;

    uint8_t* jpg_buf = NULL;
    size_t jpg_len = 0;
    bool converted = frame2jpg(fb, 40, &jpg_buf, &jpg_len);

    if (converted) {
      client.printf("--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", jpg_len);
      client.write(jpg_buf, jpg_len);
      client.println();
      free(jpg_buf);
    }

    esp_camera_fb_return(fb);
    delay(30); // frame delay
  }
}

// Task to run streamVideo() independently
void streamTask(void* pvParameters) {
  while (true) {
    streamVideo();
    vTaskDelay(1); // avoid watchdog timeout
  }
}

void setup() {
  Serial.begin(115200);

  // Motor pin setup
  pinMode(IN1, OUTPUT); pinMode(IN2, OUTPUT);
  pinMode(IN3, OUTPUT); pinMode(IN4, OUTPUT);
  stopMotors();

  // Connect Wi-Fi
  WiFi.begin(ssid, password);
  Serial.print("Connecting to WiFi");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500); Serial.print(".");
  }

  Serial.println("\nWiFi connected!");
  Serial.print("Control page: http://"); Serial.println(WiFi.localIP());
  Serial.print("Stream page:  http://"); Serial.print(WiFi.localIP()); Serial.println(":81/stream");

  // Camera + servers
  startCamera();
  controlServer.on("/", handleRoot);
  controlServer.on("/control", handleControl);
  controlServer.begin();
  streamServer.begin();

  // Launch camera stream task on core 0
  xTaskCreatePinnedToCore(
    streamTask,       // Function
    "streamTask",     // Name
    8192,             // Stack size
    NULL,             // Param
    1,                // Priority
    NULL,             // Handle
    0                 // Core 0
  );
}

void loop() {
  controlServer.handleClient();  // only handles motor control
}

How It Works

  • The ESP32-CAM hosts a video stream at http://<ESP_IP>:81/stream
  • It also hosts a control UI at http://<ESP_IP> with motor buttons
  • Clicking the buttons sends HTTP requests (/control?move=F, etc.)
  • ESP32 processes these to control motors via GPIO 12–15

Usage

  1. Upload code using FTDI
  2. Power the car (ESP32 and motor driver)
  3. Open Serial Monitor at 115200 → Get IP
  4. Open browser on phone or PC:
    • Stream page: http://<IP>:81/stream
    • Control page: http://<IP>

Add screenshots of:

  • Serial monitor showing IP
  • Web UI on phone
  • Camera stream output

Troubleshooting

ProblemFix
Motors run but don’t stopCheck IN1–IN4 wiring & stopMotors() call
Motors don’t move at allConnect ENA/ENB to 5V (jumper caps)
ESP32 reboots or crashesEnsure proper power supply & grounding
Camera fails with error 0x106Use PIXFORMAT_GRAYSCALE instead of JPEG
Control UI unresponsive during streamingUse FreeRTOS task for stream

Ideas to Improve

  • Add PWM speed control
  • Add obstacle avoidance using ultrasonic sensors
  • Add voice command control via browser
  • Record stream to SD card or capture photos
  • Turn this into a security patrol robot

Conclusion

This surveillance car is a fun and powerful IoT project that combines video streaming, robotics, and web technology — all running on a single ESP32-CAM!