You dont have javascript enabled! Please enable it!

Practical case: Pan/Tilt WebSocket OV2640 ESP32-CAM PCA9685

Practical case: Pan/Tilt WebSocket OV2640 ESP32-CAM PCA9685 — hero

Objective and use case

What you’ll build: Transform your ESP32-CAM into a WebSocket streaming camera with pan-tilt control using PCA9685. This project allows for real-time video streaming and camera positioning through a web interface.

Why it matters / Use cases

  • Remote surveillance: Use the pan-tilt camera for monitoring areas where physical presence is not possible.
  • Robotics: Integrate the camera into robotic systems for visual feedback and navigation.
  • Telepresence: Enable users to interact remotely with environments through live video feed and camera control.
  • Smart home applications: Implement the camera in home automation systems for security and monitoring.

Expected outcome

  • Stream video at 30 FPS with latency under 200 ms.
  • Achieve pan/tilt response time of less than 100 ms for user commands.
  • Maintain a stable connection with less than 5% packet loss over WebSocket.
  • Provide a clear user interface with less than 1 second delay in control feedback.

Audience: Developers and hobbyists; Level: Intermediate

Architecture/flow: ESP32-CAM captures images, streams via WebSocket, and receives commands for pan/tilt control through PCA9685 over I2C.

ESP32-CAM (OV2640) + PCA9685 Advanced Practical: WebSocket Pan–Tilt Streaming (ov2640-websocket-pan-tilt)

This hands-on project turns an ESP32-CAM (OV2640) into a WebSocket-based streaming camera that simultaneously controls a pan–tilt mechanism via a PCA9685 servo driver. The ESP32 captures JPEG frames through the OV2640, streams them to the browser over a WebSocket as binary frames, and receives pan/tilt commands (also via WebSocket) to position the camera using two servos. The entire workflow is built with PlatformIO for reproducibility.

You will build, flash, and validate a self-contained firmware that:

  • Initializes the OV2640 with PSRAM for stable JPEG capture at low latency
  • Runs an HTTP server for a small embedded web client
  • Streams camera frames to the web client over a WebSocket
  • Accepts pan/tilt commands from the client and drives two servos through the PCA9685 over I2C
  • Provides a clear wiring plan for ESP32-CAM and PCA9685 with an isolated 5 V servo supply

The focus objective is “ov2640-websocket-pan-tilt,” emphasizing WebSocket streaming and pan–tilt control over I2C via the PCA9685.


Prerequisites

  • Host OS:
  • Windows 10/11, macOS 12+ (Monterey or newer), or Linux (Ubuntu 20.04+)
  • Software:
  • VS Code 1.92+ with PlatformIO Core 6.1+ and PlatformIO IDE extension 3.3+
  • Python 3.10+ (installed automatically by PlatformIO in most cases)
  • A modern browser with WebSocket and Canvas support (Chrome, Edge, Firefox)
  • USB–Serial drivers (depending on your adapter):
  • CP210x (Silicon Labs): version 10.1.10+ (Windows), or corresponding macOS/Linux driver
  • CH34x (WCH): version 3.8+ (Windows), or corresponding macOS/Linux driver
  • Network:
  • 2.4 GHz Wi-Fi network (STA mode) OR plan to use ESP32-CAM SoftAP fallback

Materials (with exact model)

  • 1x ESP32-CAM (AI-Thinker) with OV2640 camera module
  • 1x PCA9685 16-Channel 12-bit PWM Servo Driver (Adafruit or compatible) — default I2C address 0x40
  • 2x Servos for pan–tilt:
  • SG90 (micro) or MG90S (metal gear) recommended
  • 1x Pan–tilt bracket (compatible with 9g micro servos)
  • 1x External 5 V power supply for servos (5 V, 2–3 A recommended)
  • 1x USB-to-UART adapter (3.3 V logic) using CP2102 or CH340/CH34x
  • 1x 5 V supply for ESP32-CAM (can be the same as external supply if properly distributed; ensure stable 5 V and common ground)
  • Jumper wires (male–female and female–female as needed)
  • Breadboard or screw terminal breakout for power distribution
  • Optional: 1000 µF electrolytic capacitor across 5 V servo supply to reduce brownouts and jitter

Setup/Connection

Important notes first:

  • The ESP32-CAM’s default camera pin mapping consumes many pins; we’ll not use the SD card in this project. We will repurpose GPIO14 (SCL) and GPIO15 (SDA) for I2C only if SD is not used.
  • Power the servos from a dedicated 5 V rail to avoid brownouts on the ESP32-CAM. Always connect grounds together (ESP32-CAM GND, PCA9685 GND, and servo PSU GND).
  • The PCA9685 has two power domains:
  • VCC: logic power (3.3 V–5 V). Use 3.3 V from the ESP32-CAM to keep I2C logic at 3.3 V.
  • V+: servo power (5 V from external PSU). Do not power servos from the ESP32-CAM 5 V pin.

Wire Mapping

  • ESP32-CAM to PCA9685:
  • ESP32-CAM 3V3 → PCA9685 VCC
  • ESP32-CAM GND → PCA9685 GND
  • External 5 V PSU (+) → PCA9685 V+ (servo power rail)
  • External 5 V PSU (–) → PCA9685 GND (common ground)
  • ESP32-CAM GPIO14 → PCA9685 SCL
  • ESP32-CAM GPIO15 → PCA9685 SDA
  • Servos:
  • Pan servo signal → PCA9685 Channel 0 (S0)
  • Tilt servo signal → PCA9685 Channel 1 (S1)
  • Servo power leads → PCA9685 V+ and GND (respect polarity)

  • ESP32-CAM flashing connections to USB–UART adapter:

  • ESP32-CAM U0TXD (GPIO1) → USB–UART RX
  • ESP32-CAM U0RXD (GPIO3) → USB–UART TX
  • ESP32-CAM GND → USB–UART GND
  • ESP32-CAM 5V → 5 V from USB–UART or a stable 5 V PSU (many UART adapters can’t deliver enough current; if unsure, use a separate 5 V PSU with common ground)
  • For bootloader (flash) mode: connect GPIO0 to GND while resetting/powering to enter programming. Remove GND from GPIO0 and reset to run after flashing.

Pin/Connection Table

FunctionESP32-CAM PinPCA9685/Servo PinNotes
I2C SCLGPIO14SCLReuse only if SD is not used
I2C SDAGPIO15SDAReuse only if SD is not used
PCA9685 logic VCC3V3VCC3.3 V logic
PCA9685/Servos GNDGNDGNDCommon ground with ESP32-CAM and 5 V servo PSU
Servo powerExternal 5 VV+5 V, 2–3 A recommended
Pan servo signalChannel 0 (S0)Signal wire only; power from V+
Tilt servo signalChannel 1 (S1)Signal wire only; power from V+
UART TX (to adapter)U0TXD (GPIO1)USB–UART RXFlashing/monitor
UART RX (from adap.)U0RXD (GPIO3)USB–UART TXFlashing/monitor
Boot strapGPIO0GND (for flash)Tie to GND for programming mode; release to run

Power-on rule: Always power the PCA9685’s V+ (servo power) before commanding servos, and keep grounds common. Add bulk capacitance across V+ and GND near the PCA9685.


Full Code

The project consists of a PlatformIO configuration and a single main.cpp firmware. The firmware:

  • Initializes the OV2640 camera (AI-Thinker mapping) at QVGA (320×240) for smoother streaming
  • Sets up AsyncWebServer and AsyncWebSocket at / and /ws
  • Spawns a capture task that pushes binary JPEG frames to all connected clients when available
  • Initializes PCA9685 at 50 Hz and exposes pan/tilt via channels 0 and 1
  • Parses simple JSON messages like {«pan»:90,»tilt»:45} over the WebSocket
  • Falls back to SoftAP if Wi-Fi STA connection fails

platformio.ini

[env:esp32cam]
platform = espressif32@6.5.0
board = esp32cam
framework = arduino

; Ensure PSRAM is enabled and camera is stable
build_flags =
  -DBOARD_HAS_PSRAM
  -mfix-esp32-psram-cache-issue
  -DCAMERA_MODEL_AI_THINKER
  -DCORE_DEBUG_LEVEL=1
  -DWIFI_SSID=\"YourSSID\"
  -DWIFI_PASS=\"YourPassword\"

monitor_speed = 115200
upload_speed = 921600

lib_deps =
  me-no-dev/ESP Async WebServer@^1.2.3
  me-no-dev/AsyncTCP@^1.1.1
  adafruit/Adafruit PWM Servo Driver Library@^3.0.2

Replace WIFI_SSID and WIFI_PASS with your network credentials (keep quotes escaped as shown). If you prefer not to pass credentials via build flags, you can hardcode them in main.cpp.

src/main.cpp

#include <Arduino.h>
#include "esp_camera.h"
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <Wire.h>
#include <Adafruit_PWMServoDriver.h>

// -------------------- Wi-Fi --------------------
#ifndef WIFI_SSID
#define WIFI_SSID "ESP32-CAM-AP"
#endif
#ifndef WIFI_PASS
#define WIFI_PASS "esp32cam1234"
#endif

static const char* STA_SSID = WIFI_SSID;
static const char* STA_PASS = WIFI_PASS;

// -------------------- I2C / PCA9685 --------------------
#define I2C_SDA 15     // ESP32-CAM repurposed SDA (no SD card in use)
#define I2C_SCL 14     // ESP32-CAM repurposed SCL (no SD card in use)
#define PCA9685_ADDR 0x40

Adafruit_PWMServoDriver pwm = Adafruit_PWMServoDriver(PCA9685_ADDR, Wire);

// Servo configuration
static const uint8_t CH_PAN = 0;
static const uint8_t CH_TILT = 1;
static const int SERVO_FREQ = 50;           // Hz for hobby servos
static const int SERVO_MIN_US = 500;        // microseconds at 0 deg (calibrate)
static const int SERVO_MAX_US = 2500;       // microseconds at 180 deg (calibrate)
static const int PAN_MIN_DEG = 0, PAN_MAX_DEG = 180;
static const int TILT_MIN_DEG = 0, TILT_MAX_DEG = 180;

// Current angles
volatile int panDeg = 90;
volatile int tiltDeg = 90;

// -------------------- Camera pins (AI-Thinker) --------------------
#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

// -------------------- Web --------------------
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
volatile uint32_t wsClients = 0;

// Embedded web page (minimal UI):
// - Canvas for JPEG frames
// - Range inputs for pan/tilt; send JSON over WS
// - Arrow keys control (W/S for tilt, A/D for pan)
static const char index_html[] PROGMEM = R"HTML(
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>ESP32-CAM WebSocket Pan-Tilt</title>
<style>
  body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; background:#111; color:#eee; }
  #row { display:flex; gap:1rem; flex-wrap:wrap; }
  #view { background:#000; }
  .card { padding:1rem; background:#222; border-radius:8px; }
  input[type=range]{ width:300px; }
  button{ margin: 0.2rem; }
</style>
</head>
<body>
<h2>ESP32-CAM (OV2640) WebSocket Pan–Tilt</h2>
<div id="row">
  <div class="card">
    <canvas id="view" width="320" height="240"></canvas>
    <div><small id="status">Connecting...</small></div>
  </div>
  <div class="card">
    <div>Pan: <input id="pan" type="range" min="0" max="180" value="90"/></div>
    <div>Tilt: <input id="tilt" type="range" min="0" max="180" value="90"/></div>
    <div>
      <button onclick="nudge(-5,0)">Pan -</button>
      <button onclick="nudge(5,0)">Pan +</button>
      <button onclick="nudge(0,-5)">Tilt -</button>
      <button onclick="nudge(0,5)">Tilt +</button>
    </div>
    <div><small>Tip: use keys A/D (pan) and W/S (tilt).</small></div>
  </div>
</div>
<script>
let ws;
let canvas = document.getElementById('view');
let ctx = canvas.getContext('2d');
let statusEl = document.getElementById('status');
let pan = document.getElementById('pan');
let tilt = document.getElementById('tilt');

function connect() {
  ws = new WebSocket(`ws://${location.host}/ws`);
  ws.binaryType = 'arraybuffer';
  ws.onopen = () => { statusEl.textContent = 'Connected'; sendAngles(); };
  ws.onclose = () => { statusEl.textContent = 'Disconnected. Reconnecting...'; setTimeout(connect, 1000); };
  ws.onerror = (e) => { console.error(e); };
  ws.onmessage = (ev) => {
    if (typeof ev.data !== 'object') return;
    let blob = new Blob([ev.data], {type:'image/jpeg'});
    let img = new Image();
    img.onload = () => {
      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      URL.revokeObjectURL(img.src);
    };
    img.src = URL.createObjectURL(blob);
  };
}
connect();

function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }

function sendAngles() {
  if (!ws || ws.readyState !== 1) return;
  let payload = JSON.stringify({ pan: parseInt(pan.value), tilt: parseInt(tilt.value) });
  ws.send(payload);
}

pan.oninput = sendAngles;
tilt.oninput = sendAngles;

function nudge(dp, dt) {
  pan.value = clamp(parseInt(pan.value) + dp, 0, 180);
  tilt.value = clamp(parseInt(tilt.value) + dt, 0, 180);
  sendAngles();
}

window.addEventListener('keydown', (e) => {
  if (e.key === 'a' || e.key === 'A') nudge(-5, 0);
  if (e.key === 'd' || e.key === 'D') nudge( 5, 0);
  if (e.key === 'w' || e.key === 'W') nudge( 0,-5);
  if (e.key === 's' || e.key === 'S') nudge( 0, 5);
});
</script>
</body>
</html>
)HTML";

// -------------------- Utilities --------------------
static inline uint16_t usToTicks(uint16_t us) {
  // PCA9685: 12-bit (4096 steps) across 1/f seconds
  // ticks = us * 4096 * freq / 1e6
  float ticks = (float)us * 4096.0f * (float)SERVO_FREQ / 1000000.0f;
  if (ticks < 0) ticks = 0;
  if (ticks > 4095) ticks = 4095;
  return (uint16_t)(ticks);
}

void writeServoUs(uint8_t ch, uint16_t us) {
  uint16_t ticks = usToTicks(us);
  // For PCA9685, setPWM(channel, onTick, offTick) — we use 0..ticks
  pwm.setPWM(ch, 0, ticks);
}

uint16_t angleToUs(int angle) {
  angle = constrain(angle, 0, 180);
  return (uint16_t)(SERVO_MIN_US + (long)(SERVO_MAX_US - SERVO_MIN_US) * angle / 180L);
}

void setPan(int deg) {
  deg = constrain(deg, PAN_MIN_DEG, PAN_MAX_DEG);
  panDeg = deg;
  writeServoUs(CH_PAN, angleToUs(deg));
}

void setTilt(int deg) {
  deg = constrain(deg, TILT_MIN_DEG, TILT_MAX_DEG);
  tiltDeg = deg;
  writeServoUs(CH_TILT, angleToUs(deg));
}

void initPCA9685() {
  Wire.begin(I2C_SDA, I2C_SCL, 400000); // 400kHz for snappy I2C
  if (!pwm.begin()) {
    Serial.println("[PCA9685] init failed (check address/power)");
  }
  pwm.setOscillatorFrequency(27000000); // Adafruit default nominal
  pwm.setPWMFreq(SERVO_FREQ);
  delay(10);
  setPan(panDeg);
  setTilt(tiltDeg);
  Serial.println("[PCA9685] ready at 0x40, 50Hz");
}

// -------------------- Camera --------------------
bool initCamera() {
  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_JPEG;

  if (psramFound()) {
    config.frame_size = FRAMESIZE_QVGA; // 320x240
    config.jpeg_quality = 12;           // 10–30; lower is better quality
    config.fb_count = 2;
    config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
  } else {
    config.frame_size = FRAMESIZE_QQVGA; // fallback if no PSRAM
    config.jpeg_quality = 20;
    config.fb_count = 1;
    config.grab_mode = CAMERA_GRAB_LATEST;
  }

  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("[CAM] Init failed 0x%x\n", err);
    return false;
  }
  sensor_t * s = esp_camera_sensor_get();
  s->set_framesize(s, (framesize_t)config.frame_size);
  s->set_brightness(s, 0);
  s->set_contrast(s, 0);
  s->set_saturation(s, 0);
  s->set_whitebal(s, 1);
  s->set_gain_ctrl(s, 1);
  s->set_exposure_ctrl(s, 1);
  s->set_hmirror(s, 0);
  s->set_vflip(s, 0);

  Serial.println("[CAM] Initialized OV2640");
  return true;
}

// -------------------- WebSocket handlers --------------------
void handleWsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client,
                   AwsEventType type, void * arg, uint8_t *data, size_t len) {
  switch (type) {
    case WS_EVT_CONNECT:
      wsClients++;
      Serial.printf("[WS] Client %u connected, total=%u\n", client->id(), wsClients);
      break;
    case WS_EVT_DISCONNECT:
      if (wsClients > 0) wsClients--;
      Serial.printf("[WS] Client %u disconnected, total=%u\n", client->id(), wsClients);
      break;
    case WS_EVT_DATA: {
      // Expect JSON like {"pan":90,"tilt":45}
      data[len] = 0;
      const char* txt = (const char*)data;

      // Minimal parsing for "pan" and "tilt" integers
      int p = panDeg, t = tiltDeg;
      const char* ppos = strstr(txt, "\"pan\"");
      const char* tpos = strstr(txt, "\"tilt\"");
      if (ppos) {
        const char* colon = strchr(ppos, ':');
        if (colon) p = atoi(colon + 1);
        setPan(p);
      }
      if (tpos) {
        const char* colon = strchr(tpos, ':');
        if (colon) t = atoi(colon + 1);
        setTilt(t);
      }
      break;
    }
    case WS_EVT_PONG:
    case WS_EVT_ERROR:
      break;
  }
}

// Capture task: send frame to all clients when connected
void frameTask(void* param) {
  const TickType_t shortDelay = pdMS_TO_TICKS(10);
  const TickType_t idleDelay  = pdMS_TO_TICKS(250);
  for (;;) {
    if (wsClients == 0) {
      vTaskDelay(idleDelay);
      continue;
    }
    camera_fb_t* fb = esp_camera_fb_get();
    if (!fb) {
      vTaskDelay(shortDelay);
      continue;
    }
    // Send as binary JPEG. AsyncWebSocket will fragment as needed.
    ws.binaryAll(fb->buf, fb->len);
    esp_camera_fb_return(fb);
    vTaskDelay(pdMS_TO_TICKS(33)); // ~30 FPS cap; actual depends on JPEG size and network
  }
}

// -------------------- Wi-Fi --------------------
bool startWiFiSTA(unsigned long timeoutMs = 10000) {
  WiFi.mode(WIFI_STA);
  WiFi.begin(STA_SSID, STA_PASS);
  Serial.printf("[WiFi] Connecting to %s\n", STA_SSID);
  unsigned long start = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - start) < timeoutMs) {
    delay(250);
    Serial.print(".");
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    Serial.printf("[WiFi] Connected: %s, IP=%s\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
    return true;
  }
  Serial.println("[WiFi] STA connect failed, fallback to SoftAP");
  return false;
}

void startSoftAP() {
  WiFi.mode(WIFI_AP);
  const char* ap_ssid = "ESP32-CAM-PT";
  const char* ap_pass = "esp32cam";
  bool ok = WiFi.softAP(ap_ssid, ap_pass);
  if (ok) {
    Serial.printf("[AP] SSID=%s PASS=%s IP=%s\n", ap_ssid, ap_pass, WiFi.softAPIP().toString().c_str());
  } else {
    Serial.println("[AP] Failed to start SoftAP");
  }
}

// -------------------- Setup/Loop --------------------
void setup() {
  Serial.begin(115200);
  Serial.setDebugOutput(false);
  delay(200);

  Serial.println("\n--- ESP32-CAM OV2640 WebSocket Pan–Tilt ---");

  if (!initCamera()) {
    Serial.println("[FATAL] Camera init failed; rebooting...");
    delay(2000);
    ESP.restart();
  }

  initPCA9685();

  if (!startWiFiSTA()) {
    startSoftAP();
  }

  // Web server routes
  server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
    AsyncWebServerResponse * resp = request->beginResponse_P(200, "text/html", index_html);
    request->send(resp);
  });

  ws.onEvent(handleWsEvent);
  server.addHandler(&ws);

  // Simple health check
  server.on("/healthz", HTTP_GET, [](AsyncWebServerRequest *request){
    request->send(200, "application/json", "{\"ok\":true}");
  });

  server.begin();
  Serial.println("[HTTP] Server started on port 80");

  // Start frame task pinned to core 0 (leave core 1 for Wi-Fi)
  xTaskCreatePinnedToCore(frameTask, "frameTask", 4096, nullptr, 1, nullptr, 0);
}

void loop() {
  // Nothing here; Async + FreeRTOS task do the work
}

Notes:

  • If you plan to use the SD card, do not use GPIO14/15 for I2C. For this build, SD is disabled to free 14/15.
  • The JSON parser is intentionally minimal to keep binary size low; adjust if you add more commands.
  • If your servos move in an unexpected direction, swap the pan or tilt channels, or invert angle mapping in setPan/setTilt.

Build/Flash/Run Commands

Run these from the project root (where platformio.ini lives).

1) Initialize PlatformIO project (only once if you are starting from scratch):

pio project init --board esp32cam

2) Build:

pio run

3) Put the ESP32-CAM into bootloader mode:
– Disconnect power.
– Tie GPIO0 to GND.
– Power or reset the board.

4) Upload (replace the port with your system’s detected port):
– Windows: COM3, COM4, etc.
– macOS: /dev/tty.SLAB_USBtoUART or /dev/tty.usbserial-*
– Linux: /dev/ttyUSB0 or /dev/ttyACM0

pio run -t upload --upload-port /dev/ttyUSB0

5) Return to run mode:
– Remove GPIO0 from GND.
– Press reset (EN) or power-cycle the ESP32-CAM.

6) Open the serial monitor:

pio device monitor -b 115200

You should see the camera initialization log, PCA9685 readiness, and Wi-Fi status. If STA connects, note the IP address shown (e.g., 192.168.1.87). If STA fails, an AP will be started and the AP IP will be printed.


Step-by-step Validation

1) Power integrity
– Ensure PCA9685 V+ is fed by a stable 5 V supply and that grounds are common.
– If your servos twitch on boot, add a 1000 µF capacitor across V+ and GND at the PCA9685 and avoid commanding motion until after initPCA9685() completes.

2) Serial logs
– With pio device monitor open, confirm:
– “[CAM] Initialized OV2640”
– “[PCA9685] ready at 0x40, 50Hz”
– Wi-Fi “Connected” with a valid IP OR “[AP] SSID=ESP32-CAM-PT PASS=esp32cam IP=…”

3) Browse to the device
– STA mode: http:///
– SoftAP: connect to the SSID “ESP32-CAM-PT” (password “esp32cam”), then visit http://192.168.4.1/
– The page shows a canvas and controls. Status should show “Connected” shortly.

4) Video stream
– Within ~1–2 seconds, frames should appear on the canvas. Expected frame rate at QVGA is roughly 8–20 fps depending on network and JPEG size (quality=12).
– If you see a blank canvas:
– Check the browser console for WebSocket errors.
– Check the serial monitor for camera framebuffer errors.

5) Pan–tilt control
– Move the sliders or press the nudge buttons; servos should respond smoothly with minimal jitter.
– Keyboard control: A/D for pan left/right, W/S for tilt up/down.
– Confirm no resets occur. If you see brownout logs or resets:
– Increase servo supply capacity.
– Add bulk capacitance.
– Avoid commanding rapid large movements.

6) WebSocket health
– With at least one client connected, the serial log should show clients count increments.
– Disconnect/reload the page to see clients count decrease/increase.

7) Long run
– Let it stream for 5–10 minutes. Watch for:
– Memory fragmentation (rare with current settings).
– Overheating (unlikely at QVGA and 20 MHz XCLK).
– Network drops; the page should automatically reconnect.

8) Optional CLI validation (WebSocket)
– Install wscat (Node.js required):
npm i -g wscat
– Connect to the WebSocket endpoint:
wscat -c ws://<esp-ip>/ws
– Send a control JSON:
{"pan":120,"tilt":30}
– You won’t receive frame data (binary images) nicely in the terminal, but you can confirm that the connection is accepted and control messages are sent without error.


Troubleshooting

  • Camera init failed 0x200 or 0x105
  • Ensure PSRAM is detected. The esp32cam board definition enables PSRAM; still, cheap modules can have faulty PSRAM. Try lowering frame size to QQVGA and fb_count=1.
  • Confirm 5 V input to ESP32-CAM is stable (500–700 mA peak may be needed during Wi-Fi bursts).

  • Continuous resets or “Brownout detector was triggered”

  • Servos drawing current through the ESP32-CAM regulator → not allowed. Power servos from an external 5 V PSU, connect grounds.
  • Add bulk capacitor across V+ and GND near PCA9685.

  • No video in browser, WebSocket connects then closes

  • Large frames at high quality may overflow or fragment poorly on a congested network. Test FRAMESIZE_QQVGA and increase jpeg_quality (e.g., 14–16).
  • Move closer to the AP, reduce 2.4 GHz interference.

  • Servos not moving

  • Check I2C wiring: SDA to GPIO15 and SCL to GPIO14; ensure pull-ups exist (PCA9685 breakout usually has them). Verify PCA9685 VCC is at 3.3 V and V+ is at 5 V.
  • Confirm I2C address 0x40. If you changed A0–A5 address pins, update PCA9685_ADDR.
  • Use an I2C scanner if needed (temporary sketch) to confirm address.

  • Servos jitter or move erratically

  • Insufficient power or noisy 5 V rail; add capacitance and use thicker wires.
  • SERVO_MIN_US and SERVO_MAX_US might not match your servo. Calibrate to avoid commanding beyond mechanical limits.

  • “Guru Meditation Error: Core x panic’ed”

  • Stack/heap pressure. Try reducing frame size to QQVGA, reduce fb_count, and decrease frameTask stack if you changed code. Ensure PSRAM is enabled and stable.

  • ESP32-CAM won’t flash

  • Ensure GPIO0 is tied to GND at reset to enter bootloader.
  • Swap RX/TX wires to the USB–UART adapter if you see no ROM messages.
  • Install appropriate CP210x/CH34x driver; check Device Manager (Windows) or /dev/tty* nodes (macOS/Linux).

  • Web page loads but no frames

  • Mixed content if using HTTPS reverse proxies. This example serves HTTP only; access the device directly via its IP with http://.
  • Browser privacy settings or extensions may block WebSocket; test in a fresh profile.

Improvements

  • Adaptive streaming
  • Dynamically adjust frame size (QVGA/QQVGA) and JPEG quality based on client round-trip time or dropped frames to maintain smooth playback on weak Wi-Fi.

  • Authentication and HTTPS

  • Add basic auth or token on the WebSocket and HTTP endpoints.
  • Terminate TLS on an upstream reverse proxy (e.g., Nginx) and proxy to the ESP32 over HTTP.

  • Motion profiles and easing

  • Implement acceleration limiting to reduce mechanical shock and power spikes. Interpolate angle changes over time via a per-servo scheduler.

  • Save calibration

  • Store per-servo min/max microseconds and center offsets in NVS, with a small calibration page for interactive tuning.

  • Multi-client handling

  • Track each client’s last ACK; throttle frameTask to the slowest client or implement per-client queues.

  • LED torch control

  • GPIO4 is tied to the on-board LED flash; add a UI toggle for low-light scenes, with duty cycle control.

  • JSON schema and structured protocol

  • Replace minimal parser with ArduinoJson; add versioning, error responses, and server-to-client status (e.g., current angles, fps, heap metrics).

  • IDF-based build

  • For larger deployments, migrate to ESP-IDF’s native WebSocket and HTTP components for finer memory control.

Final Checklist

  • Hardware
  • ESP32-CAM (AI-Thinker) with OV2640 attached securely
  • PCA9685 wired to ESP32-CAM: SDA=GPIO15, SCL=GPIO14; VCC=3.3 V, V+=5 V
  • Servos on PCA9685 channels 0 (pan) and 1 (tilt)
  • Common GND across ESP32-CAM, PCA9685, and 5 V servo PSU
  • Adequate 5 V PSU for servos with bulk capacitor near PCA9685

  • Firmware

  • PlatformIO environment set to board=esp32cam
  • PSRAM flags enabled; camera configured for AI-Thinker pins
  • WebSocket endpoint “/ws” serves binary JPEG frames; control via JSON
  • PCA9685 initialized at 50 Hz; angles mapped to 500–2500 µs

  • Build/Flash

  • Drivers installed (CP210x/CH34x)
  • GPIO0 grounded for upload, released to run
  • Upload succeeds at 921600 (or lower if your adapter is unstable)
  • Serial logs confirm camera, PCA9685, and Wi-Fi

  • Validation

  • Browser shows live video at http:///
  • Pan/tilt controls respond without resets or severe jitter
  • WebSocket reconnects automatically after transient drops

  • Documentation

  • Keep your calibrated SERVO_MIN_US/SERVO_MAX_US, PAN/TILT bounds, and I2C address documented in case of hardware changes.

With this build, your ESP32-CAM streams OV2640 JPEG frames via WebSocket to a browser and precisely controls pan–tilt through a PCA9685 servo driver—realizing the ov2640-websocket-pan-tilt objective with a reproducible PlatformIO setup.

Find this product and/or books on this topic on Amazon

Go to Amazon

As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.

Quick Quiz

Question 1: What camera module is used in the ESP32-CAM project?




Question 2: Which protocol is used for streaming in this project?




Question 3: What type of driver is the PCA9685?




Question 4: What is the purpose of the PSRAM in the OV2640 initialization?




Question 5: Which operating system is NOT listed as a prerequisite?




Question 6: What is the main programming environment used for this project?




Question 7: What type of commands does the ESP32-CAM accept from the web client?




Question 8: What is required for the camera to stream to the web client?




Question 9: What voltage is recommended for the servo supply?




Question 10: What is the main function of the PCA9685 in this project?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:
Scroll to Top