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
As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.



