Community project
ESP32 Remote-Controlled Car
Build a WiFi-controlled car powered by an ESP32 that streams live video while you drive it remotely. The robot uses two geared motors for propulsion, a servo for steering, and an onboard camera to see where it's going. A protected 2S battery pack keeps everything running safely.
This guide covers the complete build: wiring the battery protection board and buck converter for clean power rails, connecting the dual motor driver to control both drive motors independently, setting up the servo steering signal, and mounting the camera module. Assembly steps walk through safe testing at reduced speeds before running at full throttle, plus the firmware provides a web interface for remote control and live video streaming over WiFi.
Wiring diagram

Gather all the parts
Assemble it in 6 steps
1. Make the protected battery supply
Put two identical, equally charged 18650 cells in a proper 2-cell holder. Connect battery_1 BAT+ to bms_1 B+ (battery positive), and battery_1 BAT- to bms_1 B- (battery negative). From the protected output, connect bms_1 P+ to buck_1 VIN (buck input power) and to motor_driver_1 VM (raw motor power). Connect bms_1 P- to buck_1 GND and the shared GND rail (ground).
- Use thicker wire for the battery, VM, GND, and motor wires because these carry the most current.
- The battery-to-VM path does not go through the buck converter.
- A fully charged two-cell pack is about 8.4 V. Reversed battery wires can damage the BMS, driver, and wiring.
- Use matched lithium cells and a real 2S charger; do not charge loose cells directly.
2. Set up the 5 V rail
Before connecting the servo or ESP32, use a multimeter on buck_1 VOUT and GND and adjust its small screw until the reading is exactly 5.0 V. Connect buck_1 VOUT to servo_1 VCC, the red wire (power), and to the Freenove ESP32-S3 camera board 5V/VIN pin (power). Connect buck_1 GND to servo_1 GND, the brown or black wire (ground), and to an ESP32 GND pin (ground).
- Label the buck output wires red for 5 V and black for ground.
- A 470–1000 µF capacitor across the 5 V and GND rails close to the servo can help if the camera resets while steering.
- Do not connect the raw battery to the servo or the ESP32 5V/VIN pin — a full battery can reach 8.4 V and can damage them.
- Do not swap the servo red and brown/black wires; swapped power can damage the servo.
3. Wire the motor driver
Connect motor_driver_1 VM to bms_1 P+ (raw motor power) and motor_driver_1 GND to bms_1 P- / the shared ground (ground). Connect motor_driver_1 VCC to the ESP32 3V3 pin (logic power). Connect A01 to motor_1 M+ (motor output) and A02 to motor_1 M- (motor output). Connect AIN1 to GPIO1 (direction signal), AIN2 to GPIO2 (direction signal), PWMA to GPIO14 (slow/fast power signal), and STBY to GPIO3 (driver enable signal). Connect BIN1, BIN2, and PWMB to GND because the second motor output is unused.
- If Forward makes the wheel run backward, swap the two motor wires at A01 and A02.
- Keep the motor wires away from the camera antenna and signal wires where you can.
- Never connect the motor straight to an ESP32 pin; the motor current would damage the board.
- The 6 V motor receives PWM pulses from the 2S pack. Fast is limited in software to keep average drive near 6 V, but it does not turn an 8.4 V battery into a physical 6 V supply.
4. Connect the steering signal
Connect ESP32 GPIO38 directly to servo_1 SIG, the orange or yellow wire (steering signal). There is no level-shifter board in this version. The servo uses the ESP32's 3.3 V control signal while its red power wire stays on the separate 5 V buck output.
- Make this signal wire short and secure so steering does not glitch as the car vibrates.
- The ESP32, motor driver, buck, and servo must all share the same GND connection.
- Do not connect the servo signal wire to the 5 V power wire. That can damage the ESP32 output pin.
5. Mount and power the camera board
Secure the Freenove camera board facing forward, with its antenna area and lens clear. With the battery disconnected, check that the buck's 5 V output reaches the board's 5V/VIN pin and its GND reaches an ESP32 GND pin. Fit the servo horn to the steering linkage only after first power-up, when the program has moved the servo to centre.
- Use cable ties or hot glue so vibration cannot pull wires out.
- Keep the camera lens clear of the wheel and steering linkage.
- Remove battery power before changing any wiring or touching the steering linkage.
- Do not force the powered servo horn by hand; its small gears can strip.
6. Test slow and fast safely
Lift the driven wheel off the table. Plug the battery into the protected BMS output to power the car. Join your phone to Wi-Fi RC-CAR-CAM using password drivecar, then open http://192.168.4.1. Select Slow or Fast, then hold Forward or Reverse; releasing the button stops the motor. Use Centre steering before driving.
- Slow is selected when the page opens; use it first to check wheel direction and steering.
- The motor stops automatically if the phone stops sending drive commands.
- Keep fingers, hair, and loose wires away from the wheel and steering linkage.
- Disconnect the battery at once if you smell hot plastic, see smoke, or feel a wire becoming hot.
Review all connections
1. Connections between "battery_1" and "ESP32"
2. Connections between "motor_driver_1" and "ESP32"
3. Connections between "servo_1" and "ESP32"
4. Connections between "buck_1" and "ESP32"
5. Connections between "bms_1" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <ESP32Servo.h>
#include "esp_camera.h"
#include "esp_http_server.h"
// Freenove ESP32-S3 WROOM CAM OV2640 factory wiring.
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 15
#define SIOD_GPIO_NUM 4
#define SIOC_GPIO_NUM 5
#define Y9_GPIO_NUM 16
#define Y8_GPIO_NUM 17
#define Y7_GPIO_NUM 18
#define Y6_GPIO_NUM 12
#define Y5_GPIO_NUM 10
#define Y4_GPIO_NUM 8
#define Y3_GPIO_NUM 9
#define Y2_GPIO_NUM 11
#define VSYNC_GPIO_NUM 6
#define HREF_GPIO_NUM 7
#define PCLK_GPIO_NUM 13
// Forward declarations
void stopMotor();
void driveMotor(bool forward);
esp_err_t indexHandler(httpd_req_t *request);
esp_err_t controlHandler(httpd_req_t *request);
esp_err_t streamHandler(httpd_req_t *request);
bool startCamera();
void startWebServer();
const int MOTOR_AIN1_PIN = 1;
const int MOTOR_AIN2_PIN = 2;
const int MOTOR_STBY_PIN = 3;
const int MOTOR_PWMA_PIN = 14;
// GPIO38 connects directly to the servo's orange/yellow control wire.
const int SERVO_PIN = 38;
const char *AP_NAME = "RC-CAR-CAM";
const char *AP_PASSWORD = "drivecar";
// The BMS feeds TB6612 VM directly; the MP1584 VIN shares that protected battery rail.
// A full 2S pack is 8.4 V. These PWM limits keep average motor drive near or below its 6 V rating.
const uint8_t DRIVE_POWER_SLOW = 105; // about 3.5 V average at a full battery
const uint8_t DRIVE_POWER_FAST = 180; // about 5.9 V average at a full battery
uint8_t selectedDrivePower = DRIVE_POWER_SLOW;
const int STEER_LEFT = 55;
const int STEER_CENTRE = 90;
const int STEER_RIGHT = 125;
const unsigned long COMMAND_TIMEOUT_MS = 700;
Servo steering;
httpd_handle_t cameraServer = nullptr;
unsigned long lastDriveCommandMs = 0;
void stopMotor() {
digitalWrite(MOTOR_AIN1_PIN, LOW);
digitalWrite(MOTOR_AIN2_PIN, LOW);
ledcWrite(MOTOR_PWMA_PIN, 0);
}
void driveMotor(bool forward) {
digitalWrite(MOTOR_STBY_PIN, HIGH);
digitalWrite(MOTOR_AIN1_PIN, forward ? HIGH : LOW);
digitalWrite(MOTOR_AIN2_PIN, forward ? LOW : HIGH);
ledcWrite(MOTOR_PWMA_PIN, selectedDrivePower);
lastDriveCommandMs = millis();
}
const char INDEX_HTML[] PROGMEM = R"HTML(
<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>RC Car Camera</title><style>body{margin:0;background:#15191f;color:#fff;font:18px Arial;text-align:center}h2{margin:14px}img{width:min(100%,640px);background:#000}.grid{display:grid;grid-template-columns:repeat(3,96px);gap:10px;justify-content:center;margin:16px}button{height:58px;border:0;border-radius:12px;background:#2684ff;color:#fff;font-size:17px;font-weight:bold}.stop{background:#e5484d}.note{font-size:13px;color:#b9c4d0;margin:8px 18px 20px}</style></head><body><h2>RC Car Camera</h2><img src="/stream" alt="Camera stream"><div class="grid"><div></div><button id="forward">Forward</button><div></div><button onclick="go('left')">Left</button><button class="stop" onclick="go('stop')">Stop</button><button onclick="go('right')">Right</button><div></div><button id="reverse">Reverse</button><div></div><button onclick="go('centre')">Centre steering</button></div><p><button id="slow" onclick="speed('slow')">Slow</button> <button id="fast" onclick="speed('fast')">Fast</button></p><p class="note">Choose a speed, then hold Forward or Reverse to drive. Releasing the button stops the motor.</p><script>function go(c){fetch('/control?cmd='+c).catch(()=>{});}function speed(s){go(s);document.getElementById('slow').style.background=s==='slow'?'#26a269':'#2684ff';document.getElementById('fast').style.background=s==='fast'?'#26a269':'#2684ff';}function hold(id,cmd){let t;const b=document.getElementById(id);const start=e=>{e.preventDefault();go(cmd);t=setInterval(()=>go(cmd),200);};const end=e=>{e.preventDefault();clearInterval(t);go('stop');};b.addEventListener('pointerdown',start);b.addEventListener('pointerup',end);b.addEventListener('pointerleave',end);b.addEventListener('pointercancel',end);}speed('slow');hold('forward','forward');hold('reverse','reverse');</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 controlHandler(httpd_req_t *request) {
char query[64] = {0};
char command[16] = {0};
if (httpd_req_get_url_query_str(request, query, sizeof(query)) == ESP_OK) httpd_query_key_value(query, "cmd", command, sizeof(command));
if (strcmp(command, "forward") == 0) driveMotor(true);
else if (strcmp(command, "reverse") == 0) driveMotor(false);
else if (strcmp(command, "stop") == 0) stopMotor();
else if (strcmp(command, "slow") == 0) selectedDrivePower = DRIVE_POWER_SLOW;
else if (strcmp(command, "fast") == 0) selectedDrivePower = DRIVE_POWER_FAST;
else if (strcmp(command, "left") == 0) steering.write(STEER_LEFT);
else if (strcmp(command, "right") == 0) steering.write(STEER_RIGHT);
else if (strcmp(command, "centre") == 0) steering.write(STEER_CENTRE);
httpd_resp_set_type(request, "text/plain");
return httpd_resp_sendstr(request, "OK");
}
esp_err_t streamHandler(httpd_req_t *request) {
const char *contentType = "multipart/x-mixed-replace;boundary=frame";
const char *boundary = "\r\n--frame\r\n";
char header[64];
httpd_resp_set_type(request, contentType);
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) {
int length = snprintf(header, sizeof(header), "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", (unsigned)frame->len);
result = httpd_resp_send_chunk(request, header, length);
}
if (result == ESP_OK) result = httpd_resp_send_chunk(request, (const char *)frame->buf, frame->len);
esp_camera_fb_return(frame);
if (result != ESP_OK) return result;
}
}
bool 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_sccb_sda = SIOD_GPIO_NUM; config.pin_sccb_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_JPEG;
config.frame_size = FRAMESIZE_QVGA;
config.jpeg_quality = 12;
config.fb_count = 2;
config.fb_location = CAMERA_FB_IN_PSRAM;
config.grab_mode = CAMERA_GRAB_LATEST;
return esp_camera_init(&config) == ESP_OK;
}
void startWebServer() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = 80;
if (httpd_start(&cameraServer, &config) != ESP_OK) return;
httpd_uri_t indexUri = {}; indexUri.uri = "/"; indexUri.method = HTTP_GET; indexUri.handler = indexHandler;
httpd_uri_t controlUri = {}; controlUri.uri = "/control"; controlUri.method = HTTP_GET; controlUri.handler = controlHandler;
httpd_uri_t streamUri = {}; streamUri.uri = "/stream"; streamUri.method = HTTP_GET; streamUri.handler = streamHandler;
httpd_register_uri_handler(cameraServer, &indexUri);
httpd_register_uri_handler(cameraServer, &controlUri);
httpd_register_uri_handler(cameraServer, &streamUri);
}
void setup() {
Serial.begin(115200);
pinMode(MOTOR_AIN1_PIN, OUTPUT); pinMode(MOTOR_AIN2_PIN, OUTPUT); pinMode(MOTOR_STBY_PIN, OUTPUT);
digitalWrite(MOTOR_STBY_PIN, HIGH);
ledcAttach(MOTOR_PWMA_PIN, 20000, 8);
stopMotor();
steering.setPeriodHertz(50);
steering.attach(SERVO_PIN, 500, 2400);
steering.write(STEER_CENTRE);
if (!startCamera()) { Serial.println("Camera initialization failed"); return; }
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_NAME, AP_PASSWORD);
Serial.print("Open http://"); Serial.println(WiFi.softAPIP());
startWebServer();
}
void loop() {
if (lastDriveCommandMs != 0 && millis() - lastDriveCommandMs > COMMAND_TIMEOUT_MS) { stopMotor(); lastDriveCommandMs = 0; }
delay(10);
}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.




