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.




