esp32 intermediate 40 min

ESP32-CAM: take and send a photo on motion via ntfy

PIR sensor triggers the ESP32-CAM, which captures a JPEG and pushes it to your phone over ntfy. Self-hosted security camera, no cloud.

Code available for: ESP32 ArduinoArduino C
Published Sep 22, 2026

A security camera that does the right thing: stays silent until something moves, then puts the photo on your phone. This version sends the image through ntfy (the self-hosted push service), so the whole path is yours: camera to server to phone, nothing leaves your network unless you want it to.

This is the project version of the streaming tutorial. Same board, but instead of watching a live stream, you get a photo pushed to you when it matters.

What you need

  • ESP32-CAM board (AI-Thinker, about $10)
  • HC-SR501 PIR motion sensor (about $2)
  • FTDI adapter (for programming, as always with this board)
  • The ntfy app on your phone, subscribed to a topic (e.g. “driveway-cam-k4p9”) per the ntfy tutorial

Wiring

Wire key: VCC5VGNDGPIO
PIRESP32-CAM
VCC5V
GNDGND
OUTGPIO 13

GPIO 13 is free on the ESP32-CAM (it is not part of the camera or SD bus). The HC-SR501’s output is 3.3V logic even on 5V supply, so no level shifter needed.

Two PIR adjustments worth making with the little pot screwdrivers:

PotSetting
Sensitivity (left)Middle to start; clockwise = more range
Time delayFully counterclockwise (minimum hold time, ~3 s)

The HC-SR501 has a ~3 second retrigger lockout after each trigger (e.g. a cat walking by produces one notification, not forty).

The code

#include "esp_camera.h"
#include <WiFi.h>
#include <HTTPClient.h>

// AI-Thinker pin map (same as the streaming tutorial)
#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 Y2_GPIO_NUM     5
#define Y3_GPIO_NUM    18
#define Y4_GPIO_NUM    19
#define Y5_GPIO_NUM    21
#define Y6_GPIO_NUM    36
#define Y7_GPIO_NUM    39
#define Y8_GPIO_NUM    34
#define Y9_GPIO_NUM    35
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM  23
#define PCLK_GPIO_NUM  22

#define PIR_PIN 13

const char* NTFY_TOPIC = "https://ntfy.sh/driveway-cam-k4p9";
// Self-hosted: http://192.168.1.50:25800/driveway-cam-k4p9

bool cameraReady = false;

void setup() {
  Serial.begin(115200);
  pinMode(PIR_PIN, INPUT);

  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_sccb_sda = SIOD_GPIO_NUM;
  config.pin_sccb_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;
  config.frame_size = FRAMESIZE_VGA;
  config.jpeg_quality = 12;
  config.fb_count = 1;

  cameraReady = (esp_camera_init(&config) == ESP_OK);
  Serial.println(cameraReady ? "camera ok" : "camera FAILED (check power)");

  WiFi.mode(WIFI_STA);
  WiFi.begin("your-wifi", "your-password");
  while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print("."); }
  Serial.println("\nready");
}

void loop() {
  if (cameraReady && digitalRead(PIR_PIN) == HIGH) {
    sendPhoto("Motion in the driveway");
    delay(10000);   // cool-down: one photo per event, not per wave
  }
  delay(200);
}

void sendNtfy(String title, String message) {
  WiFiClient wc;
  HTTPClient http;
  http.begin(wc, NTFY_TOPIC);
  http.addHeader("Content-Type", "text/plain");
  http.addHeader("X-Title", title);
  http.POST(message);
  http.end();
}

void sendPhotoNtfy() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) return;

  WiFiClient wc;
  HTTPClient http;
  http.begin(wc, NTFY_TOPIC);
  http.addHeader("Content-Type", "image/jpeg");
  http.addHeader("X-Title", "Motion detected");
  // ntfy accepts a raw binary body as the attachment
  http.POST(String((const char*)fb->buf), fb->len);
  http.end();
  esp_camera_fb_return(fb);
}

void sendPhotoNtfy() { /* see above */ }

(For the real build, keep sendNtfy for text alerts and sendPhotoNtfy for the image; the multipart form version of the attachment is in the library docs, the raw-body version above works against both ntfy.sh and a self-hosted server.)

The PIR trigger physics

PIR sensors read moving infrared (warm bodies against the background). They false-trigger on: HVAC vents blowing warm air, direct sun through a window, and anything moving fast across their field of view (e.g. the classic “car headlights sweeping the wall” trigger). Mount looking down from chest height and mask the sensor’s edge zones with electrical tape if it covers a window.

The Wi-Fi reconnect trap

The photo send can take 2-4 seconds of Wi-Fi. If the PIR fires while Wi-Fi is down (router rebooted overnight), the POST fails silently. The production pattern checks connectivity before trusting an alert:

bool wifiOk() {
  if (WiFi.status() == WL_CONNECTED) return true;
  WiFi.disconnect();
  WiFi.reconnect();
  delay(3000);
  return WiFi.status() == WL_CONNECTED;
}

Alerts during an outage are lost; a heartbeat message every hour (“still alive, no motion”) tells you the device itself is alive vs broken (e.g. distinguish “no motion” from “camera fell off the Wi-Fi”).

What you learned

  • PIR into a GPIO that has a spare pin (GPIO 13 here) is the whole input side.
  • ntfy accepts a raw JPEG body; the phone shows it as an attachment.
  • The cool-down delay after a trigger is what keeps one event from becoming fifty notifications.

When something breaks

  • PIR never fires: HC-SR501 has a warm-up of its own (60 s or so of instability after power-up). Also check the jumper on the PIR: H (retrigger) is what you want, not L.
  • Photos but no notification: the POST return code again. 200 is delivered. A 429 is rate-limiting; self-host if you are triggering often.
  • Photo arrives but is garbage pixels: the board’s power supply again, or jpeg_quality set below 10 (which over-compresses).
  • Works inside, dies outside: Wi-Fi out of range of the driveway. The ESP32-CAM antenna is a PCB trace, not a hero. Add the external antenna connector version of the board for real range.

What to build next

  • The trail camera project is this plus battery + solar + SD storage for the field version.
  • The streaming tutorial is the live-view sibling.
  • The book IoT with ESP32 bundles the camera tutorials.