ESP32-CAM Pan-Tilt Servo Camera with Live Stream and Web Controls

A fixed camera sees only one angle. Mount the ESP32-CAM on a pan-tilt bracket with two small servos and it can look around a room, follow a pet or act as the head of a surveillance robot. The browser page shows the live stream with buttons to turn left, right, up and down.

The interesting part of this project is resource sharing. The camera already uses an LEDC channel and timer to generate its clock, most GPIOs are taken, and servos cause current spikes that reset the board. This tutorial deals with each of these and gives a complete, working sketch.

How Hobby Servos Are Driven

An SG90 or MG90S servo expects a pulse every 20 ms (50 Hz). The pulse width sets the angle: roughly 500 microseconds for one end, about 1500 for the centre and about 2400 for the other end. Exact limits vary between servos, so the code lets you adjust them. The ESP32 LEDC peripheral can produce this signal in hardware.

duty_counts = pulse_us x 2^bits / 20000

With 14-bit resolution (16384 counts per 20 ms):
  500 us  -> 500 x 16384 / 20000  = 410
  1500 us -> 1500 x 16384 / 20000 = 1229
  2400 us -> 2400 x 16384 / 20000 = 1966

The camera driver configures LEDC timer 0 and channel 0 for the 20 MHz XCLK. If a servo is attached to channel 0 or retimes timer 0 to 50 Hz, the camera stops working. In Arduino core 3.x, ledcAttachChannel(pin, freq, bits, channel) lets you pick channels explicitly; channels 2 and 3 share a timer other than timer 0, and both servos use the same 50 Hz frequency, so they can share it safely. In core 2.x use ledcSetup(2, 50, 14) and ledcAttachPin(14, 2) instead.

Hardware and Wiring

PartConnectionNotes
Pan servo signalGPIO 14SD card not used
Tilt servo signalGPIO 15Strapping pin; only affects boot messages
Servo red wiresSeparate 5 V supply, 2 A or moreNot the ESP32-CAM 5V pin
Servo brown wiresSupply GND and ESP32-CAM GNDCommon ground is required
ESP32-CAM 5VOwn 5 V supply or regulator outputAdd bulk capacitor near the board
BracketPlastic SG90 pan-tilt kitGlue or screw the camera board to the tilt plate

The same structure is used for a surveillance car. Replace the servos with an L298N or TB6612 motor driver on GPIO 12, 13, 14 and 15, keep GPIO 12 LOW at boot with a pull-down, and power the motors from a separate battery.

Complete Code

Pan-tilt camera: MJPEG stream on port 81, servo control at /move on port 80 (Arduino core 3.x)
#include <WiFi.h>
#include "esp_camera.h"
#include "esp_http_server.h"

const char *ssid = "YourWiFiName";
const char *password = "YourWiFiPassword";

// AI-Thinker ESP32-CAM pin map
#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

#define PAN_PIN   14
#define TILT_PIN  15
#define PAN_CH     2            // channels 2/3 avoid the camera's timer 0
#define TILT_CH    3
#define SERVO_BITS 14
#define MIN_US   500            // adjust for your servos
#define MAX_US  2400
#define STEP_DEG  10

int panAngle = 90, tiltAngle = 90;
httpd_handle_t webServer = NULL, streamServer = NULL;

bool initCamera() {
  camera_config_t c = {};
  c.ledc_channel = LEDC_CHANNEL_0;
  c.ledc_timer   = LEDC_TIMER_0;
  c.pin_d0 = Y2_GPIO_NUM;  c.pin_d1 = Y3_GPIO_NUM;
  c.pin_d2 = Y4_GPIO_NUM;  c.pin_d3 = Y5_GPIO_NUM;
  c.pin_d4 = Y6_GPIO_NUM;  c.pin_d5 = Y7_GPIO_NUM;
  c.pin_d6 = Y8_GPIO_NUM;  c.pin_d7 = Y9_GPIO_NUM;
  c.pin_xclk = XCLK_GPIO_NUM;   c.pin_pclk = PCLK_GPIO_NUM;
  c.pin_vsync = VSYNC_GPIO_NUM; c.pin_href = HREF_GPIO_NUM;
  c.pin_sccb_sda = SIOD_GPIO_NUM; c.pin_sccb_scl = SIOC_GPIO_NUM;
  c.pin_pwdn = PWDN_GPIO_NUM;   c.pin_reset = RESET_GPIO_NUM;
  c.xclk_freq_hz = 20000000;
  c.pixel_format = PIXFORMAT_JPEG;
  c.frame_size   = FRAMESIZE_VGA;
  c.jpeg_quality = 12;
  c.fb_count     = 2;
  c.fb_location  = CAMERA_FB_IN_PSRAM;
  c.grab_mode    = CAMERA_GRAB_LATEST;
  return esp_camera_init(&c) == ESP_OK;
}

void writeServo(int pin, int angle) {
  angle = constrain(angle, 0, 180);
  uint32_t us = map(angle, 0, 180, MIN_US, MAX_US);
  uint32_t duty = us * (1UL << SERVO_BITS) / 20000UL;  // 20 ms period
  ledcWrite(pin, duty);
}

static esp_err_t index_handler(httpd_req_t *req) {
  static const char page[] =
    "<!DOCTYPE html><html><head><meta name='viewport' content='width=device-width'>"
    "<style>button{width:70px;height:50px;font-size:18px;margin:4px}</style></head>"
    "<body style='text-align:center;font-family:sans-serif'>"
    "<img id='v' style='width:100%;max-width:640px'><br>"
    "<button onclick=\"go('up')\">Up</button><br>"
    "<button onclick=\"go('left')\">Left</button>"
    "<button onclick=\"go('center')\">Home</button>"
    "<button onclick=\"go('right')\">Right</button><br>"
    "<button onclick=\"go('down')\">Down</button>"
    "<script>document.getElementById('v').src=location.protocol+'//'+"
    "location.hostname+':81/stream';"
    "function go(d){fetch('/move?dir='+d);}</script></body></html>";
  httpd_resp_set_type(req, "text/html");
  return httpd_resp_send(req, page, HTTPD_RESP_USE_STRLEN);
}

static esp_err_t move_handler(httpd_req_t *req) {
  char query[32], dir[10];
  if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
      httpd_query_key_value(query, "dir", dir, sizeof(dir)) != ESP_OK) {
    httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing dir");
    return ESP_FAIL;
  }
  if (!strcmp(dir, "left"))   panAngle += STEP_DEG;
  if (!strcmp(dir, "right"))  panAngle -= STEP_DEG;
  if (!strcmp(dir, "up"))     tiltAngle -= STEP_DEG;
  if (!strcmp(dir, "down"))   tiltAngle += STEP_DEG;
  if (!strcmp(dir, "center")) { panAngle = 90; tiltAngle = 90; }
  panAngle  = constrain(panAngle, 0, 180);
  tiltAngle = constrain(tiltAngle, 30, 150);   // keep cable from pulling
  writeServo(PAN_PIN, panAngle);
  writeServo(TILT_PIN, tiltAngle);
  char reply[32];
  snprintf(reply, sizeof(reply), "pan=%d tilt=%d", panAngle, tiltAngle);
  return httpd_resp_send(req, reply, HTTPD_RESP_USE_STRLEN);
}

static esp_err_t stream_handler(httpd_req_t *req) {
  static const char *SEP = "\r\n--frame\r\n";
  char hdr[64];
  httpd_resp_set_type(req, "multipart/x-mixed-replace;boundary=frame");
  while (true) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) return ESP_FAIL;
    int n = snprintf(hdr, sizeof(hdr),
                     "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", (unsigned)fb->len);
    esp_err_t res = httpd_resp_send_chunk(req, SEP, strlen(SEP));
    if (res == ESP_OK) res = httpd_resp_send_chunk(req, hdr, n);
    if (res == ESP_OK) res = httpd_resp_send_chunk(req, (const char *)fb->buf, fb->len);
    esp_camera_fb_return(fb);
    if (res != ESP_OK) return res;           // viewer left
  }
}

void startServers() {
  httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
  httpd_uri_t indexUri = {}; indexUri.uri = "/";     indexUri.method = HTTP_GET; indexUri.handler = index_handler;
  httpd_uri_t moveUri  = {}; moveUri.uri  = "/move"; moveUri.method  = HTTP_GET; moveUri.handler  = move_handler;
  if (httpd_start(&webServer, &cfg) == ESP_OK) {
    httpd_register_uri_handler(webServer, &indexUri);
    httpd_register_uri_handler(webServer, &moveUri);
  }
  cfg.server_port = 81;
  cfg.ctrl_port += 1;
  httpd_uri_t streamUri = {}; streamUri.uri = "/stream"; streamUri.method = HTTP_GET; streamUri.handler = stream_handler;
  if (httpd_start(&streamServer, &cfg) == ESP_OK) {
    httpd_register_uri_handler(streamServer, &streamUri);
  }
}

void setup() {
  Serial.begin(115200);
  if (!initCamera()) {                  // camera first: it claims timer 0
    Serial.println("Camera init failed");
    while (true) delay(1000);
  }
  ledcAttachChannel(PAN_PIN, 50, SERVO_BITS, PAN_CH);
  ledcAttachChannel(TILT_PIN, 50, SERVO_BITS, TILT_CH);
  writeServo(PAN_PIN, panAngle);
  writeServo(TILT_PIN, tiltAngle);

  WiFi.begin(ssid, password);
  WiFi.setSleep(false);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.printf("\nOpen http://%s/\n", WiFi.localIP().toString().c_str());
  startServers();
}

void loop() {
  delay(1000);
}

How the Code Works

  • The camera is initialised before the servos, so it configures timer 0 and channel 0 first. The servos are then attached to channels 2 and 3 explicitly, both at 50 Hz with 14-bit resolution.
  • writeServo() maps 0 to 180 degrees onto the pulse range and converts microseconds to duty counts using the formula above.
  • Each button press calls /move?dir=... with fetch(), so the page and stream are not reloaded. The handler parses the query with httpd_query_key_value() and moves in 10 degree steps.
  • Tilt is limited to 30 to 150 degrees so the camera ribbon and power wires are not strained at the extremes.
  • Control runs on port 80 and the stream on port 81, so buttons stay responsive while video plays.

To calibrate, open /move?dir=center and check that both servos sit in the middle of their travel. Then step to each end and listen: a servo that keeps humming at the limit is being pushed past its mechanical stop, so reduce MAX_US or raise MIN_US by 50 to 100 microseconds until it stays quiet. If the image moves the wrong way for a button, swap the plus and minus signs for that direction in move_handler().

Troubleshooting

  • Image freezes or camera fails when servos attach: a servo is on LEDC channel 0 or 1, or on timer 0. Use the explicit channels shown.
  • Board resets when a servo moves: servo current is reaching the ESP32-CAM supply. Use a separate servo supply with common ground.
  • Servo buzzes at the end of travel: MIN_US or MAX_US is beyond its mechanical range; narrow the limits.
  • Servos jump at power-up: GPIO 14 and 15 change state during boot. Start at the centre angle and keep the bracket clear.

Summary

Two SG90 servos on GPIO 14 and 15, driven by LEDC channels that keep away from the camera's timer 0, turn the ESP32-CAM into a pan-tilt camera you can steer from a browser. Give the servos their own 5 V supply, limit the tilt range, and keep control and streaming on separate HTTP server instances.