Skip to content

ESP32-CAM

A camera node that is one board and a USB cable. The ESPHome config in this folder brings up an OV2640 camera, a 0.96in OLED that shows whatever you want it to show, a PIR and a button — and every one of those parts is on the same PCB, so there is nothing to solder, nothing to wire and nothing to print. Twenty dollars gets you a camera entity and a motion binary_sensor in Home Assistant from the same device.

This config is for a TTGO T-Camera, not a bare ESP32-CAM. The folder is called esp32-cam and the two names get used interchangeably, but the boards are not interchangeable. The camera pins in bruh-esp32-cam.yaml — external clock on GPIO32, SCCB on 13/12, pixel clock on 19 — are the T-Camera’s, and the AI-Thinker ESP32-CAM that everyone means by “ESP32-CAM” uses a different set entirely. It also has no OLED, no PIR and no button, so the display: block and both binary_sensor: blocks would have nothing to talk to. Buy the T-Camera and this config flashes and runs. Buy the other one and you are writing a new config.

Parts

Some links below are Amazon affiliate links. As an Amazon Associate I earn from qualifying purchases — at no extra cost to you. It helps keep these guides free. Outside the US, they should send you to your own Amazon store; if one doesn't, searching the part number there will find it.

The T-Camera sells in a few lens and sensor variants. If yours arrived without the PIR populated, an external one on GPIO33 does exactly the same job:

And if you specifically want the AI-Thinker board instead, it is a different part and needs a different config — read the caution above first.

Pins

Every pin here is read straight out of esphome/bruh-esp32-cam.yaml. You don’t wire any of them. They are here so you can recognise the board, and so you know what is already taken if you add something.

FunctionGPIO
Camera external clock (XCLK)32, at 20 MHz
Camera SCCB — SDA / SCL13 / 12
Camera data D0–D75, 14, 4, 15, 18, 23, 36, 39
VSYNC27
HREF25
Pixel clock19
OLED I2C — SDA / SCL21 / 22
PIR33
Button34

The camera and the display sit on two separate I2C buses, and that is deliberate. The i2c: block at the top of the config (21/22) is the OLED’s; the i2c_pins: inside esp32_camera: (13/12) is the camera’s SCCB bus, which the camera driver owns. Collapsing them into one bus is the first tidy-up people try and it stops the camera initialising.

Flashing

  1. Create secrets.yaml in your ESPHome directory with the five keys this config uses: wifi_ssid, wifi_password, api_key, ota_password and ap_password. api_key is the API encryption key — ESPHome generates one for you when you create a device in the dashboard.

  2. Drop Roboto-Medium.ttf next to the YAML. The font: block references it by bare filename, so ESPHome looks for it in the same directory as the config. Miss this and the compile fails before it ever reaches the camera code, with an error about a font and nothing about anything else. Any TTF works — change the filename in the config to match whatever you drop in.

  3. Rename the node. There are no substitutions in this config, so name: and friendly_name: under esphome: are edited directly, along with the fallback hotspot SSID under wifi.ap. Do this before you flash a second one, or you end up with two devices called esp32-cam-1.

  4. Flash over USB the first time. Every flash after that goes over WiFi — ota: is already in the config.

  5. Watch the log on first boot. The camera either initialises or it doesn’t, and the log tells you which within the first couple of seconds.

It appears in Home Assistant on its own. ESPHome devices are discovered, so there is no YAML to write on the Home Assistant side.

What you get

A camera entity, a PIR Sensor binary sensor with device_class: motion, and a Button binary sensor. The motion sensor is the useful half — it is a real PIR on the same board as the lens, so “notify me with a picture when something moves” is one automation, with no cloud, no subscription, and no coordination between two separate devices.

Set your expectations on the video. ESPHome’s camera is not an RTSP source; Home Assistant pulls frames over the native API, and at the 640×480 this config asks for that is fine for what tripped that automation and poor for watching the driveway. If you want smooth live video, buy a real camera. This is a sensor that takes pictures.

The OLED is the part people underuse. Out of the box the lambda prints "Hello World!" and that is all it does — a placeholder, not a feature. The display is driven entirely on-device, so to put Home Assistant data on it you import the values you want as homeassistant sensors in ESPHome and print those in the lambda instead: room temperature, the state of the alarm, who is home. The rotation: 180 line is there so the text reads the right way up in one particular orientation; if yours comes out upside down, that is the line to delete.

Troubleshooting

It boots, connects, then resets. Repeatedly. Power. An ESP32 with the camera and the radio both awake pulls hard in short bursts, and a phone charger that claims 1A sags on those bursts until the brownout detector fires. Use a 2A-plus supply and a short, thick cable. If the 16 ft extension is in the loop, take it out first while you are diagnosing.

The camera never initialises. Check the module actually says WROVER. The framebuffer for a 640×480 frame lives in PSRAM, and PSRAM is the difference between a WROVER module and a plain WROOM one — a WROOM ESP32 with an OV2640 bolted to it cannot hold the frame however the config is written. That is the second reason a bare AI-Thinker swap is not a swap.

The compile fails with an error about a font. See step 2. The TTF has to be sitting beside the YAML.

The button reads stuck, or backwards. GPIO34 is one of the ESP32’s input-only pins and it has no internal pull-up at all — the chip does not have one to offer on that pin. The config declares it bare, which leans on the board’s own external pull-up, and that means the pin reads low when the button is pressed. If it reports inverted, add inverted: true to that binary_sensor.

Motion triggers constantly. A PIR pointed at a heat source — a vent, a radiator, sun tracking across a wall — fires all day. Move it before you change any settings. The camera wants to be looking at the room rather than at the ceiling anyway, which usually fixes both at once.

It drops off WiFi. The WROVER-B’s PCB antenna is not strong, and a camera node tends to end up stuck to a wall in the exact corner of the house with the worst coverage. Rotating the board 90° is worth more than you would expect; try that before you buy a repeater.

Files & downloads

ESPHome configuration

Copy this into your ESPHome directory and adjust the substitutions at the top. Secrets are referenced by name — see secrets.yaml.example .

bruh-esp32-cam.yaml 84 lines
bruh-esp32-cam.yaml
# BRUH ESP32-CAM
# ────────────────────────────────────────────────────────────
# ESP32-CAM module with OLED display status and motion detection.
# Captures video/photos and provides real-time monitoring.
#
# Hardware:
# - Board: ESP32 with integrated OV2640 camera
# - Sensor: PIR motion detector
# - Display: SSD1306 OLED for status
# ────────────────────────────────────────────────────────────
esphome:
name: esp32-cam-1
friendly_name: "ESP32 Camera"
esp32:
board: esp32dev
framework:
type: arduino
logger:
api:
encryption:
key: !secret api_key
ota:
password: !secret ota_password
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Esp32-Cam-1 Fallback Hotspot"
password: !secret ap_password
captive_portal:
i2c:
sda: 21
scl: 22
font:
- file: "Roboto-Medium.ttf"
id: roboto
size: 14
display:
- platform: ssd1306_i2c
model: "SSD1306 128x64"
rotation: 180
lambda: |-
it.print(0, 10, id(roboto), "Hello World!");
esp32_camera:
name: Camera
external_clock:
pin: 32
frequency: 20MHz
i2c_pins:
sda: 13
scl: 12
data_pins:
- 5
- 14
- 4
- 15
- 18
- 23
- 36
- 39
vsync_pin: 27
href_pin: 25
pixel_clock_pin: 19
resolution: 640x480
binary_sensor:
- platform: gpio
pin: 33
name: "PIR Sensor"
device_class: motion
- platform: gpio
pin: 34
name: "Button"

View on GitHub · Download raw

Every file for this project on GitHub →