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

Function ESP32-CAM Pin PCA9685/Servo Pin Notes
I2C SCL GPIO14 SCL Reuse only if SD is not used
I2C SDA GPIO15 SDA Reuse only if SD is not used
PCA9685 logic VCC 3V3 VCC 3.3 V logic
PCA9685/Servos GND GND GND Common ground with ESP32-CAM and 5 V servo PSU
Servo power External 5 V V+ 5 V, 2–3 A recommended
Pan servo signal Channel 0 (S0) Signal wire only; power from V+
Tilt servo signal Channel 1 (S1) Signal wire only; power from V+
UART TX (to adapter) U0TXD (GPIO1) USB–UART RX Flashing/monitor
UART RX (from adap.) U0RXD (GPIO3) USB–UART TX Flashing/monitor
Boot strap GPIO0 GND (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:
error: Contenido Protegido / Content is protected !!
Scroll to Top