Hackster will be offline on Monday, June 15 from 5pm to 7pm PDT to perform some scheduled maintenance.
Rihan Babu
Published

ESP32-CAM Smartdoor with Security Station

Using WIFI, the camera module streams to a local server. A second device then fetches these frames and projects them to a display.

IntermediateFull instructions provided2 hours92
ESP32-CAM Smartdoor with Security Station

Things used in this project

Hardware components

ESP8266 ESP-12E
Espressif ESP8266 ESP-12E
×1
ESP32 Camera Module Development Board
M5Stack ESP32 Camera Module Development Board
×1
STR7735 128*160 LCD Display
×1
PIR Motion Sensor (generic)
PIR Motion Sensor (generic)
×1
Power Supply Module
Just a generic buck/boost convertor that provides 5V and 3.3V, given a DC input of (9v - 12v).
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)

Story

Read more

Code

Camera Module

C/C++
Code for camera module
/*
 * ============================================================
 *  DIY Smart Doorbell    ESP32-CAM (AI-Thinker)
 * ============================================================
 *  Pin assignments
 *    GPIO 12  Doorbell button   (3.3V via button  GPIO12, internal pull-down)
 *    GPIO  2  PIR motion sensor (active HIGH on motion)
 *    GPIO  4  Flash LED         (app-controlled flashlight + stunlock strobe)
 *    GPIO 15  Event indicator   (blinks on button / motion)
 *
 *  Network endpoints
 *    Port 80  HTTP  web UI, snapshot, controls, /event polling
 *    Port 81  TCP   dedicated MJPEG stream task for the display unit
 *                    (raw socket so it never blocks port 80)
 *
 *  Push notifications via ntfy.sh (free, no account).
 *  Install the ntfy app and subscribe to your NTFY_TOPIC.
 *
 *   GPIO 12 is a boot-strapping pin. Keep button NOT pressed
 *    (GPIO12 LOW via internal pull-down) during boot.
 *   GPIO 2 is also a boot-strapping pin. Most PIR modules
 *    output LOW at idle, so this is fine  but if the module
 *    outputs HIGH at boot, the ESP32 will fail to boot. If
 *    that happens, move PIR to GPIO 13 instead.
 * ============================================================
 */

#include "esp_camera.h"
#include <WiFi.h>
#include "esp_http_server.h"
#include <HTTPClient.h>
#include <lwip/sockets.h>

//  User config 
#define WIFI_SSID       "WIFI-NAME"
#define WIFI_PASSWORD   "WIFI-PASSWORD"

#define NTFY_TOPIC      "squid"      // change to your UNIQUE  ntfy topic

#define BUTTON_COOLDOWN_S    5        // min seconds between button alerts
#define MOTION_COOLDOWN_S   10        // min seconds between motion alerts
#define PIR_WARMUP_S        30        // ignore PIR for first 30s after boot
                                       // (sensor needs to settle  false triggers
                                       //  during this window are normal)
// 

// AI-Thinker pin map
#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

#define BUTTON_PIN     12
#define PIR_PIN         2
#define FLASH_PIN       4
#define EVENT_LED_PIN  15

#define STREAM_PORT     81
#define STREAM_BOUNDARY "mjpeg_boundary_esp32"
#define CONTENT_TYPE_STREAM \
  "multipart/x-mixed-replace;boundary=" STREAM_BOUNDARY

//  ISR flags 
volatile bool g_buttonFlag = false;
volatile bool g_motionFlag = false;
unsigned long g_lastButtonMs = 0;
unsigned long g_lastMotionMs = 0;
unsigned long g_bootMs       = 0;     // set once in setup()  for PIR warmup

httpd_handle_t g_httpServer = NULL;

//  Last event (polled by display unit) 
String        g_lastEventType = "none";
unsigned long g_lastEventTs   = 0;

//  App-controlled state 
bool g_ledOn         = false;
bool g_motionEnabled = true;
bool g_videoFlipped  = true;

//  Stunlock task 
#define STUNLOCK_TIMEOUT_S  60
#define STUNLOCK_ON_MS      40
#define STUNLOCK_OFF_MS     40

volatile bool g_stunlockActive = false;
TaskHandle_t  g_stunlockTask   = NULL;

void stunlockTaskFn(void*) {
  unsigned long t = millis();
  while (g_stunlockActive) {
    digitalWrite(FLASH_PIN, HIGH); vTaskDelay(pdMS_TO_TICKS(STUNLOCK_ON_MS));
    digitalWrite(FLASH_PIN, LOW);  vTaskDelay(pdMS_TO_TICKS(STUNLOCK_OFF_MS));
    if (millis() - t >= (unsigned long)STUNLOCK_TIMEOUT_S * 1000UL)
      g_stunlockActive = false;
  }
  digitalWrite(FLASH_PIN, g_ledOn ? HIGH : LOW);
  g_stunlockTask = NULL;
  vTaskDelete(NULL);
}

//  Interrupts 
void IRAM_ATTR onButtonPress() { g_buttonFlag = true; }
void IRAM_ATTR onMotion()      { g_motionFlag = true; }

void blinkFlash(int n = 2) {
  for (int i = 0; i < n; i++) {
    digitalWrite(EVENT_LED_PIN, HIGH); delay(120);
    digitalWrite(EVENT_LED_PIN, LOW);  delay(80);
  }
}

//  ntfy.sh notification 
void sendNotification(const char* title, const char* body,
                      const char* tags,  const char* priority) {
  if (WiFi.status() != WL_CONNECTED) return;
  String camURL = "http://" + WiFi.localIP().toString() + "/";
  HTTPClient http;
  http.begin("https://ntfy.sh/" + String(NTFY_TOPIC));
  http.addHeader("Content-Type", "text/plain");
  http.addHeader("Title",    title);
  http.addHeader("Tags",     tags);
  http.addHeader("Priority", priority);
  http.addHeader("Actions",  "view, View Live Feed, " + camURL + ", clear=true");
  int code = http.POST(body);
  http.end();
  Serial.printf("[ntfy] HTTP %d\n", code);
}

// 
//  Port-81 QQVGA MJPEG stream task
//
//  Runs on a dedicated FreeRTOS task with a raw BSD socket, so
//  the long-running stream loop never touches the HTTP server
//  on port 80.  One display client at a time; on disconnect the
//  camera resolution is restored.
// 
static bool sendAll(int fd, const void* data, size_t len) {
  const char* p = (const char*)data;
  while (len > 0) {
    int n = send(fd, p, len, 0);
    if (n <= 0) return false;
    p += n; len -= n;
  }
  return true;
}

void qqStreamTask(void*) {
  int serverFd = socket(AF_INET, SOCK_STREAM, 0);
  if (serverFd < 0) { vTaskDelete(NULL); return; }

  int opt = 1;
  setsockopt(serverFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

  struct sockaddr_in addr = {};
  addr.sin_family      = AF_INET;
  addr.sin_addr.s_addr = INADDR_ANY;
  addr.sin_port        = htons(STREAM_PORT);

  if (bind(serverFd, (struct sockaddr*)&addr, sizeof(addr)) < 0 ||
      listen(serverFd, 1) < 0) {
    Serial.println("[qqstream] bind/listen failed");
    close(serverFd); vTaskDelete(NULL); return;
  }
  Serial.printf("[qqstream] Listening on port %d\n", STREAM_PORT);

  while (true) {
    int clientFd = accept(serverFd, NULL, NULL);
    if (clientFd < 0) { vTaskDelay(pdMS_TO_TICKS(100)); continue; }
    Serial.println("[qqstream] Display connected");

    // 2 s recv timeout, 3 s send timeout
    struct timeval rcv = { .tv_sec = 2, .tv_usec = 0 };
    struct timeval snd = { .tv_sec = 3, .tv_usec = 0 };
    setsockopt(clientFd, SOL_SOCKET, SO_RCVTIMEO, &rcv, sizeof(rcv));
    setsockopt(clientFd, SOL_SOCKET, SO_SNDTIMEO, &snd, sizeof(snd));

    //  Drain HTTP request (read in chunks until \r\n\r\n) 
    {
      char  rbuf[512];
      int   total = 0;
      while (total < (int)sizeof(rbuf) - 1) {
        int n = recv(clientFd, rbuf + total, sizeof(rbuf) - 1 - total, 0);
        if (n <= 0) break;
        total += n; rbuf[total] = 0;
        if (strstr(rbuf, "\r\n\r\n")) break;
      }
    }

    //  Send HTTP response headers 
    const char* hdr =
      "HTTP/1.1 200 OK\r\n"
      "Content-Type: multipart/x-mixed-replace;boundary=" STREAM_BOUNDARY "\r\n"
      "Access-Control-Allow-Origin: *\r\n"
      "Cache-Control: no-cache, no-store\r\n"
      "Connection: close\r\n\r\n";
    if (!sendAll(clientFd, hdr, strlen(hdr))) {
      close(clientFd); continue;
    }

    //  Switch to QQVGA for the duration of the connection 
    sensor_t*   s    = esp_camera_sensor_get();
    framesize_t prev = (framesize_t)s->status.framesize;
    s->set_framesize(s, FRAMESIZE_QQVGA);

    //  Stream frames until client disconnects 
    char partHdr[80];
    bool ok = true;
    while (ok) {
      camera_fb_t* fb = esp_camera_fb_get();
      if (!fb) { vTaskDelay(pdMS_TO_TICKS(10)); continue; }

      uint8_t* jpgBuf = fb->buf;
      size_t   jpgLen = fb->len;
      uint8_t* tmpBuf = nullptr;

      if (fb->format != PIXFORMAT_JPEG) {
        bool conv = frame2jpg(fb, 80, &tmpBuf, &jpgLen);
        esp_camera_fb_return(fb); fb = nullptr;
        if (!conv) break;
        jpgBuf = tmpBuf;
      }

      const char* boundary = "\r\n--" STREAM_BOUNDARY "\r\n";
      int hLen = snprintf(partHdr, sizeof(partHdr),
                          "Content-Type: image/jpeg\r\n"
                          "Content-Length: %u\r\n\r\n", jpgLen);

      ok = sendAll(clientFd, boundary, strlen(boundary)) &&
           sendAll(clientFd, partHdr,  hLen)             &&
           sendAll(clientFd, jpgBuf,   jpgLen);

      if (fb)     esp_camera_fb_return(fb);
      if (tmpBuf) free(tmpBuf);
    }

    s->set_framesize(s, prev);
    close(clientFd);
    Serial.println("[qqstream] Display disconnected");
  }

  close(serverFd);
  vTaskDelete(NULL);
}

// 
//  HTTP handlers (port 80)
// 
static esp_err_t streamHandler(httpd_req_t* req) {
  httpd_resp_set_type(req, CONTENT_TYPE_STREAM);
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  httpd_resp_set_hdr(req, "Cache-Control", "no-cache, no-store");
  char partHeader[64];
  while (true) {
    camera_fb_t* fb = esp_camera_fb_get();
    if (!fb) break;
    uint8_t* jpgBuf = fb->buf; size_t jpgLen = fb->len;
    uint8_t* tmpBuf = nullptr;
    if (fb->format != PIXFORMAT_JPEG) {
      bool c = frame2jpg(fb, 80, &tmpBuf, &jpgLen);
      esp_camera_fb_return(fb); fb = nullptr;
      if (!c) break; jpgBuf = tmpBuf;
    }
    esp_err_t res = httpd_resp_send_chunk(req, "\r\n--" STREAM_BOUNDARY "\r\n",
                      strlen("\r\n--" STREAM_BOUNDARY "\r\n"));
    int hLen = snprintf(partHeader, sizeof(partHeader),
                        "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n", jpgLen);
    if (res == ESP_OK) res = httpd_resp_send_chunk(req, partHeader, hLen);
    if (res == ESP_OK) res = httpd_resp_send_chunk(req, (const char*)jpgBuf, jpgLen);
    if (fb)     esp_camera_fb_return(fb);
    if (tmpBuf) free(tmpBuf);
    if (res != ESP_OK) break;
  }
  httpd_resp_send_chunk(req, nullptr, 0);
  return ESP_OK;
}

static esp_err_t snapshotHandler(httpd_req_t* req) {
  camera_fb_t* fb = esp_camera_fb_get();
  if (!fb) { httpd_resp_send_500(req); return ESP_FAIL; }
  httpd_resp_set_type(req, "image/jpeg");
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
  esp_err_t res = httpd_resp_send(req, (const char*)fb->buf, fb->len);
  esp_camera_fb_return(fb); return res;
}

static esp_err_t thumbHandler(httpd_req_t* req) {
  sensor_t* s = esp_camera_sensor_get();
  framesize_t prev = (framesize_t)s->status.framesize;
  s->set_framesize(s, FRAMESIZE_QQVGA);
  camera_fb_t* fb = esp_camera_fb_get();
  s->set_framesize(s, prev);
  if (!fb) { httpd_resp_send_500(req); return ESP_FAIL; }
  httpd_resp_set_type(req, "image/jpeg");
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
  esp_err_t res = httpd_resp_send(req, (const char*)fb->buf, fb->len);
  esp_camera_fb_return(fb); return res;
}

static esp_err_t eventHandler(httpd_req_t* req) {
  String json = "{\"type\":\"" + g_lastEventType + "\",\"ts\":" + String(g_lastEventTs) + "}";
  httpd_resp_set_type(req, "application/json");
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  httpd_resp_send(req, json.c_str(), json.length()); return ESP_OK;
}

static esp_err_t ledHandler(httpd_req_t* req) {
  char buf[12] = {};
  if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
    g_ledOn = strstr(buf, "on=1");
    digitalWrite(FLASH_PIN, g_ledOn ? HIGH : LOW);
  }
  httpd_resp_set_type(req, "application/json");
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  String r = String("{\"led\":") + (g_ledOn ? "true" : "false") + "}";
  httpd_resp_send(req, r.c_str(), r.length()); return ESP_OK;
}

static esp_err_t motionHandler(httpd_req_t* req) {
  char buf[12] = {};
  if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK)
    g_motionEnabled = strstr(buf, "on=1");
  httpd_resp_set_type(req, "application/json");
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  String r = String("{\"motion\":") + (g_motionEnabled ? "true" : "false") + "}";
  httpd_resp_send(req, r.c_str(), r.length()); return ESP_OK;
}

static esp_err_t stunlockHandler(httpd_req_t* req) {
  char buf[12] = {};
  if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
    bool on = strstr(buf, "on=1");
    if (on && !g_stunlockActive) {
      g_stunlockActive = true;
      xTaskCreate(stunlockTaskFn, "stunlock", 2048, NULL, 2, &g_stunlockTask);
    } else if (!on) g_stunlockActive = false;
  }
  httpd_resp_set_type(req, "application/json");
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  String r = String("{\"stunlock\":") + (g_stunlockActive ? "true" : "false") + "}";
  httpd_resp_send(req, r.c_str(), r.length()); return ESP_OK;
}

static esp_err_t flipHandler(httpd_req_t* req) {
  char buf[12] = {};
  if (httpd_req_get_url_query_str(req, buf, sizeof(buf)) == ESP_OK) {
    g_videoFlipped = strstr(buf, "on=1");
    sensor_t* s = esp_camera_sensor_get();
    s->set_vflip(s,   g_videoFlipped ? 1 : 0);
    s->set_hmirror(s, g_videoFlipped ? 1 : 0);
  }
  httpd_resp_set_type(req, "application/json");
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  String r = String("{\"flip\":") + (g_videoFlipped ? "true" : "false") + "}";
  httpd_resp_send(req, r.c_str(), r.length()); return ESP_OK;
}

static esp_err_t statusHandler(httpd_req_t* req) {
  httpd_resp_set_type(req, "application/json");
  httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
  String r = String("{\"led\":")  + (g_ledOn         ? "true" : "false") +
             ",\"motion\":"       + (g_motionEnabled  ? "true" : "false") +
             ",\"flip\":"         + (g_videoFlipped   ? "true" : "false") +
             ",\"stunlock\":"     + (g_stunlockActive ? "true" : "false") + "}";
  httpd_resp_send(req, r.c_str(), r.length()); return ESP_OK;
}

static esp_err_t indexHandler(httpd_req_t* req) {
  const char* html = R"rawhtml(<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<title>Doorbell</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{--bg:#0a0a0a;--card:#131313;--card2:#191919;--border:#222;--text:#f0ede8;--sub:#5a5855;--green:#22c55e;--amber:#f59e0b;--red:#ef4444}
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100dvh;display:flex;flex-direction:column}
header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 14px;border-bottom:1px solid var(--border)}
.logo{font-size:15px;font-weight:600;letter-spacing:-.02em;display:flex;align-items:center;gap:8px}
.logo-icon{width:28px;height:28px;background:var(--card2);border:1px solid var(--border);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px}
.badge{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--sub);background:var(--card);padding:5px 10px;border-radius:20px;border:1px solid var(--border)}
.dot{width:6px;height:6px;border-radius:50%;background:var(--sub);transition:background .3s,box-shadow .3s}
.dot.live{background:var(--green);box-shadow:0 0 8px #22c55e55}
.dot.err{background:var(--red)}
.feed-wrap{flex:1;background:#000;display:flex;align-items:center;justify-content:center;position:relative;min-height:180px}
#feed{width:100%;max-height:56dvh;object-fit:contain;display:none}
.overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px}
.overlay.hidden{display:none}
.ov-icon{font-size:28px;opacity:.25}
.ov-msg{font-size:13px;color:var(--sub)}
.fps-tag{position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,.65);color:var(--sub);font-size:10px;font-family:monospace;padding:3px 8px;border-radius:20px;display:none}
.panel{padding:14px 16px;display:flex;flex-direction:column;gap:8px;border-top:1px solid var(--border)}
.row{display:flex;align-items:center;justify-content:space-between;background:var(--card);border:1px solid var(--border);border-radius:14px;padding:14px 16px}
.row-left{display:flex;align-items:center;gap:12px}
.ri{font-size:18px;width:34px;height:34px;background:var(--card2);border:1px solid var(--border);border-radius:10px;display:flex;align-items:center;justify-content:center}
.rl{font-size:14px;font-weight:500}
.rs{font-size:11px;color:var(--sub);margin-top:2px}
.tog{position:relative;width:46px;height:26px;flex-shrink:0;cursor:pointer}
.tog input{opacity:0;width:0;height:0;position:absolute}
.ttrack{position:absolute;inset:0;background:#252525;border-radius:13px;transition:background .2s;border:1px solid var(--border)}
.ttrack::before{content:'';position:absolute;width:20px;height:20px;background:#fff;border-radius:50%;top:2px;left:2px;transition:transform .2s;box-shadow:0 1px 4px rgba(0,0,0,.5)}
.tog-led input:checked~.ttrack{background:var(--amber);border-color:var(--amber)}
.tog-mot input:checked~.ttrack{background:var(--green);border-color:var(--green)}
.tog-flip input:checked~.ttrack{background:var(--blue,#3b82f6);border-color:var(--blue,#3b82f6)}
.tog input:checked~.ttrack::before{transform:translateX(20px)}
.actions{display:flex;gap:8px;padding:0 16px 24px}
.btn{flex:1;padding:14px 0;background:var(--card);border:1px solid var(--border);border-radius:14px;color:var(--text);font-size:14px;font-weight:500;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:7px;transition:background .15s}
.btn:active{background:var(--card2)}
.sep{height:1px;background:var(--border);margin:0 16px}
.stunlock-wrap{padding:12px 16px 24px}
.stunlock-btn{width:100%;padding:16px;border-radius:14px;border:1px solid #3a1515;background:#1a0a0a;color:#f87171;font-size:15px;font-weight:600;cursor:pointer;letter-spacing:.01em;transition:background .15s,border-color .15s,color .15s;display:flex;align-items:center;justify-content:center;gap:8px}
.stunlock-btn:active{background:#2a1010}
.stunlock-btn.active{background:#7f1d1d;border-color:#ef4444;color:#fca5a5;animation:pulse-red 1s ease-in-out infinite}
@keyframes pulse-red{0%,100%{box-shadow:0 0 0 0 #ef444430}50%{box-shadow:0 0 0 8px #ef444400}}
</style>
</head>
<body>
<header>
  <div class='logo'><div class='logo-icon'></div>Doorbell Cam</div>
  <div class='badge'><span id='st'>Offline</span><span class='dot' id='dot'></span></div>
</header>
<div class='feed-wrap'>
  <img id='feed' alt=''>
  <div class='overlay' id='ov'><div class='ov-icon'></div><div class='ov-msg' id='ovm'>Connecting</div></div>
  <div class='fps-tag' id='fps'></div>
</div>
<div class='panel'>
  <div class='row'>
    <div class='row-left'><div class='ri'></div><div><div class='rl'>Flashlight</div><div class='rs' id='ls'>Off</div></div></div>
    <label class='tog tog-led'><input type='checkbox' id='lc' onchange='setLed(this.checked)'><span class='ttrack'></span></label>
  </div>
  <div class='row'>
    <div class='row-left'><div class='ri'></div><div><div class='rl'>Motion sensor</div><div class='rs' id='ms'>Enabled</div></div></div>
    <label class='tog tog-mot'><input type='checkbox' id='mc' checked onchange='setMot(this.checked)'><span class='ttrack'></span></label>
  </div>
  <div class='row'>
    <div class='row-left'><div class='ri'></div><div><div class='rl'>Flip video</div><div class='rs' id='fs'>Normal</div></div></div>
    <label class='tog tog-flip'><input type='checkbox' id='fc' onchange='setFlip(this.checked)'><span class='ttrack'></span></label>
  </div>
</div>
<div class='sep'></div>
<div class='actions'><button class='btn' onclick='snap()'> Snapshot</button></div>
<div class='sep'></div>
<div class='stunlock-wrap'>
  <button class='stunlock-btn' id='slbtn' onclick='toggleStunlock()'> Stunlock</button>
</div>
<script>
const F=document.getElementById('feed'),D=document.getElementById('dot'),
  S=document.getElementById('st'),O=document.getElementById('ov'),
  OM=document.getElementById('ovm'),FP=document.getElementById('fps');
let run=true,lu=null,fr=0,ft=Date.now(),er=0;
async function tick(){
  if(!run)return;
  try{
    const r=await fetch('/snapshot?t='+Date.now(),{cache:'no-store'});
    if(!r.ok)throw 0;
    const u=URL.createObjectURL(await r.blob());
    F.src=u;F.style.display='block';O.classList.add('hidden');
    if(lu)URL.revokeObjectURL(lu);lu=u;er=0;
    D.className='dot live';S.textContent='Live';FP.style.display='block';fr++;
    const n=Date.now();if(n-ft>=1000){FP.textContent=fr+' fps';fr=0;ft=n;}
  }catch(e){
    er++;D.className='dot err';S.textContent='Offline';O.classList.remove('hidden');
    F.style.display='none';OM.textContent=er>3?'Camera unreachable':'Reconnecting';
    await new Promise(r=>setTimeout(r,1500));
  }
  requestAnimationFrame(tick);
}
tick();
async function setLed(v){document.getElementById('ls').textContent=v?'On':'Off';await fetch('/led?on='+(v?1:0));}
async function setMot(v){document.getElementById('ms').textContent=v?'Enabled':'Disabled';await fetch('/motion?on='+(v?1:0));}
async function setFlip(v){document.getElementById('fs').textContent=v?'Flipped 180':'Normal';await fetch('/flip?on='+(v?1:0));}
let slActive=false;
async function toggleStunlock(){slActive=!slActive;updateStunlockBtn(slActive);await fetch('/stunlock?on='+(slActive?1:0));}
function updateStunlockBtn(on){
  const b=document.getElementById('slbtn');
  b.textContent=on?' Stop stunlock':' Stunlock';
  b.classList.toggle('active',on);slActive=on;
}
async function snap(){
  try{const r=await fetch('/snapshot?t='+Date.now(),{cache:'no-store'});
  const u=URL.createObjectURL(await r.blob());
  const a=document.createElement('a');a.href=u;a.download='doorbell-'+Date.now()+'.jpg';a.click();
  setTimeout(()=>URL.revokeObjectURL(u),3000);}catch(e){alert('Failed: '+e);}
}
fetch('/status').then(r=>r.json()).then(d=>{
  document.getElementById('lc').checked=d.led;document.getElementById('mc').checked=d.motion;
  document.getElementById('fc').checked=d.flip||false;
  document.getElementById('ls').textContent=d.led?'On':'Off';
  document.getElementById('ms').textContent=d.motion?'Enabled':'Disabled';
  document.getElementById('fs').textContent=d.flip?'Flipped 180':'Normal';
  if(d.stunlock)updateStunlockBtn(true);
}).catch(()=>{});
document.addEventListener('visibilitychange',()=>{run=!document.hidden;if(run)tick();});
</script>
</body>
</html>)rawhtml";
  httpd_resp_set_type(req, "text/html");
  httpd_resp_send(req, html, strlen(html));
  return ESP_OK;
}

void startServer() {
  httpd_config_t cfg  = HTTPD_DEFAULT_CONFIG();
  cfg.server_port      = 80;
  cfg.max_uri_handlers = 12;

  const httpd_uri_t routes[] = {
    { "/",         HTTP_GET, indexHandler,    nullptr },
    { "/stream",   HTTP_GET, streamHandler,   nullptr },
    { "/snapshot", HTTP_GET, snapshotHandler, nullptr },
    { "/thumb",    HTTP_GET, thumbHandler,    nullptr },
    { "/event",    HTTP_GET, eventHandler,    nullptr },
    { "/led",      HTTP_GET, ledHandler,      nullptr },
    { "/motion",   HTTP_GET, motionHandler,   nullptr },
    { "/flip",     HTTP_GET, flipHandler,     nullptr },
    { "/stunlock", HTTP_GET, stunlockHandler, nullptr },
    { "/status",   HTTP_GET, statusHandler,   nullptr },
  };

  if (httpd_start(&g_httpServer, &cfg) == ESP_OK) {
    for (const auto& r : routes)
      httpd_register_uri_handler(g_httpServer, &r);
    Serial.println("[server] HTTP server started on port 80");
  } else {
    Serial.println("[server] Failed to start HTTP server!");
  }
}

// 
//  setup()
// 
void setup() {
  Serial.begin(115200);
  Serial.println("\n[boot] Starting up");

  //  Output pins 
  pinMode(FLASH_PIN,     OUTPUT); digitalWrite(FLASH_PIN,     LOW);
  pinMode(EVENT_LED_PIN, OUTPUT); digitalWrite(EVENT_LED_PIN, LOW);

  //  Button: GPIO12 with internal pull-down 
  pinMode(BUTTON_PIN, INPUT_PULLDOWN);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onButtonPress, RISING);

  //  PIR motion sensor on GPIO2 
  // No pull resistor  the PIR module drives the line actively.
  // Will start triggering interrupts immediately, but loop()
  // ignores them for the first PIR_WARMUP_S seconds while the
  // sensor's pyroelectric element settles.
  pinMode(PIR_PIN, INPUT);
  attachInterrupt(digitalPinToInterrupt(PIR_PIN), onMotion, RISING);

  //  Camera 
  camera_config_t camCfg = {};
  camCfg.ledc_channel = LEDC_CHANNEL_0; camCfg.ledc_timer = LEDC_TIMER_0;
  camCfg.pin_d0 = Y2_GPIO_NUM; camCfg.pin_d1 = Y3_GPIO_NUM;
  camCfg.pin_d2 = Y4_GPIO_NUM; camCfg.pin_d3 = Y5_GPIO_NUM;
  camCfg.pin_d4 = Y6_GPIO_NUM; camCfg.pin_d5 = Y7_GPIO_NUM;
  camCfg.pin_d6 = Y8_GPIO_NUM; camCfg.pin_d7 = Y9_GPIO_NUM;
  camCfg.pin_xclk     = XCLK_GPIO_NUM; camCfg.pin_pclk     = PCLK_GPIO_NUM;
  camCfg.pin_vsync    = VSYNC_GPIO_NUM; camCfg.pin_href    = HREF_GPIO_NUM;
  camCfg.pin_sscb_sda = SIOD_GPIO_NUM;  camCfg.pin_sscb_scl = SIOC_GPIO_NUM;
  camCfg.pin_pwdn     = PWDN_GPIO_NUM;  camCfg.pin_reset    = RESET_GPIO_NUM;
  camCfg.xclk_freq_hz = 20000000;
  camCfg.pixel_format = PIXFORMAT_JPEG;

  if (psramFound()) {
    camCfg.frame_size = FRAMESIZE_VGA; camCfg.jpeg_quality = 12; camCfg.fb_count = 2;
    Serial.println("[cam] PSRAM  VGA");
  } else {
    camCfg.frame_size = FRAMESIZE_QVGA; camCfg.jpeg_quality = 15; camCfg.fb_count = 1;
    Serial.println("[cam] No PSRAM  QVGA");
  }

  if (esp_camera_init(&camCfg) != ESP_OK) {
    Serial.println("[cam] Init failed");
    while (true) { blinkFlash(5); delay(1000); }
  }
  Serial.println("[cam] Camera ready");

  sensor_t* s = esp_camera_sensor_get();
  s->set_brightness(s, 1);
  s->set_saturation(s, 0);
  s->set_gainceiling(s, (gainceiling_t)4);
  s->set_vflip(s,   g_videoFlipped ? 1 : 0);
  s->set_hmirror(s, g_videoFlipped ? 1 : 0);

  //  WiFi 
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  Serial.print("[wifi] Connecting");
  int retries = 0;
  while (WiFi.status() != WL_CONNECTED && retries < 30) {
    delay(500); Serial.print("."); retries++;
  }

  if (WiFi.status() == WL_CONNECTED) {
    Serial.printf("\n[wifi] IP: %s\n", WiFi.localIP().toString().c_str());
    Serial.printf("   Web UI:  http://%s/\n",   WiFi.localIP().toString().c_str());
    Serial.printf("   Display: %s:%d\n",        WiFi.localIP().toString().c_str(), STREAM_PORT);
    Serial.printf("   PIR warm-up: %d seconds\n\n", PIR_WARMUP_S);
    blinkFlash(3);
    startServer();
    xTaskCreate(qqStreamTask, "qqstream", 4096, NULL, 2, NULL);
  } else {
    Serial.println("\n[wifi] Failed");
    while (true) { blinkFlash(10); delay(2000); }
  }

  // Mark boot time AFTER WiFi connect so PIR warm-up starts now,
  // not back when setup() began (which could be mid-WiFi-connect).
  g_bootMs = millis();
  // Discard any stray PIR triggers that fired during init.
  g_motionFlag = false;
}

// 
//  loop()
// 
void loop() {
  unsigned long now = millis();

  //  Doorbell button 
  if (g_buttonFlag) {
    g_buttonFlag = false;
    unsigned long e = (now - g_lastButtonMs) / 1000UL;
    if (e >= BUTTON_COOLDOWN_S || g_lastButtonMs == 0) {
      g_lastButtonMs  = now;
      g_lastEventType = "button";
      g_lastEventTs   = now;
      Serial.println("[event] Doorbell pressed");
      blinkFlash(2);
      sendNotification("Doorbell", "Someone is at the door!", "bell", "high");
    } else {
      Serial.printf("[event] Button  cooldown (%lus left)\n",
                    (unsigned long)BUTTON_COOLDOWN_S - e);
    }
  }

  //  PIR motion 
  if (g_motionFlag) {
    g_motionFlag = false;

    // Warm-up period  discard early triggers while sensor settles
    if (now - g_bootMs < (unsigned long)PIR_WARMUP_S * 1000UL) {
      unsigned long left = (unsigned long)PIR_WARMUP_S - (now - g_bootMs) / 1000UL;
      Serial.printf("[motion] Ignored  PIR warming up (%lus left)\n", left);
    }
    else if (!g_motionEnabled) {
      // Disabled from the app  silently ignore
    }
    else {
      unsigned long e = (now - g_lastMotionMs) / 1000UL;
      if (e >= MOTION_COOLDOWN_S || g_lastMotionMs == 0) {
        g_lastMotionMs  = now;
        g_lastEventType = "motion";
        g_lastEventTs   = now;
        Serial.println("[event] Motion detected");
        blinkFlash(1);
        sendNotification("Motion Alert", "Motion detected at the door", "eyes", "default");
      } else {
        Serial.printf("[event] Motion  cooldown (%lus left)\n",
                      (unsigned long)MOTION_COOLDOWN_S - e);
      }
    }
  }

  //  WiFi watchdog 
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[wifi] Lost  reconnecting");
    WiFi.reconnect();
    delay(5000);
  }

  delay(10);
}

Display Module

C/C++
Code for the display Module
/*
 * ============================================================
 *  DIY Smart Doorbell  Display Unit (ESP-12E + ST7735)
 * ============================================================
 *  Wiring (NodeMCU  ST7735, all hardware SPI)
 *    Pin 1 RST  D1 (GPIO5)
 *    Pin 2 CS   D8 (GPIO15)
 *    Pin 3 D/C  D2 (GPIO4)
 *    Pin 4 DIN  D7 (GPIO13)
 *    Pin 5 CLK  D5 (GPIO14)
 *    Pin 6 VCC  3.3V
 *    Pin 7 BL   3.3V
 *    Pin 8 GND  GND
 *
 *  Libraries (Arduino Library Manager)
 *    - TFT_eSPI     by Bodmer
 *    - TJpg_Decoder by Bodmer
 *    - ArduinoJson  by Benoit Blanchon (v6)
 *
 *  TFT_eSPI/User_Setup.h must contain:
 *    #define ST7735_DRIVER
 *    #define TFT_WIDTH  128
 *    #define TFT_HEIGHT 160
 *    #define ST7735_BLACKTAB
 *    #define TFT_CS   15
 *    #define TFT_DC    4
 *    #define TFT_RST   5
 *    #define TFT_MOSI 13
 *    #define TFT_SCLK 14
 *    #define SPI_FREQUENCY  40000000
 *
 *  How it works
 *    - Live feed: persistent TCP connection to camera port 81.
 *      The camera streams MJPEG with Content-Length on each
 *      frame; we parse Content-Length and read exactly that
 *      many bytes per JPEG.  No reconnect overhead per frame.
 *    - Events:    short HTTP GET /event on port 80 every 600 ms.
 *      When a new event timestamp appears, a coloured banner
 *      shows on top of the feed for 5 seconds.
 * ============================================================
 */

#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <TFT_eSPI.h>
#include <TJpg_Decoder.h>
#include <ArduinoJson.h>

//  Config 
#define WIFI_SSID     "WIFI-NAME"       // change this 
#define WIFI_PASSWORD "WIFI-PASSWORD"   // change this 
#define CAM_IP        "10.231.1.30"     // paste this from the camera module   // code. It will appear in Serial Console. 
// 

// Display layout (landscape: 160128)
#define DISP_W   160
#define DISP_H   128
#define IMG_H    120   // QQVGA frame height  leaves 8 px for status bar
#define BAR_Y    120

// QQVGA JPEG at quality 12 is 37 KB.  12 KB is a safe ceiling.
#define MAX_JPEG  12288

#define LABEL_MS  5000   // event banner duration on screen

#define EVENT_INTERVAL_MS  600   // how often to poll /event

// Colours (RGB565)
#define C_BLACK   0x0000
#define C_GREEN   0x07E0
#define C_RED     0xF800
#define C_ORANGE  0xFD20
#define C_CYAN    0x07FF
#define C_DGRAY   0x39E7

//  Globals 
TFT_eSPI    tft;
WiFiClient  g_stream;                 // persistent stream socket  port 81
static uint8_t frameBuf[MAX_JPEG];    // static, reused every frame

// State
unsigned long g_lastEventTs   = 0;
String        g_lastEventType = "";
bool          g_labelOn       = false;
unsigned long g_labelAt       = 0;

unsigned long g_lastCheck     = 0;
int           g_frames        = 0;
int           g_fps           = 0;
unsigned long g_lastFpsMs     = 0;
bool          g_live          = false;

//  TJpgDec block-draw callback 
bool tftBlock(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t* bmp) {
  if (y >= IMG_H) return false;
  tft.pushImage(x, y, w, h, bmp);
  return true;
}

//  Status bar 
void drawBar() {
  tft.fillRect(0, BAR_Y, DISP_W, DISP_H - BAR_Y, C_BLACK);
  tft.setTextSize(1);
  if (!g_live) {
    tft.setTextColor(C_RED);
    tft.setCursor(3, BAR_Y + 1);
    tft.print("No signal");
    return;
  }
  tft.setTextColor(C_GREEN);
  tft.setCursor(3, BAR_Y + 1);
  tft.print("LIVE");
  if (g_fps > 0) {
    tft.setTextColor(C_DGRAY);
    String s = String(g_fps) + " fps";
    tft.setCursor(DISP_W - (int)s.length() * 6 - 2, BAR_Y + 1);
    tft.print(s);
  }
}

//  Event banner over top of frame 
void drawLabel() {
  uint16_t col = (g_lastEventType == "button") ? C_ORANGE : C_CYAN;
  tft.fillRect(0, 0, DISP_W, 13, col);
  tft.setTextColor(C_BLACK);
  tft.setTextSize(1);
  tft.setCursor(4, 3);
  tft.print(g_lastEventType == "button" ? "  DOORBELL!" : "  MOTION!");
}

//  Centred "offline" message 
void showOffline(const char* msg) {
  tft.fillRect(0, 0, DISP_W, IMG_H, C_BLACK);
  tft.setTextColor(C_DGRAY);
  tft.setTextSize(1);
  int cx = (DISP_W - (int)strlen(msg) * 6) / 2;
  tft.setCursor(max(0, cx), IMG_H / 2 - 4);
  tft.print(msg);
}

// 
//  /event polling (port 80, short-lived connection)
// 
void checkEvent() {
  WiFiClient ec;
  if (!ec.connect(CAM_IP, 80)) return;
  ec.setTimeout(1500);
  ec.print(
    "GET /event HTTP/1.1\r\n"
    "Host: " CAM_IP "\r\n"
    "Connection: close\r\n\r\n"
  );

  // Skip response headers (read until blank line)
  char line[128];
  while (ec.connected() || ec.available()) {
    int n = ec.readBytesUntil('\n', line, sizeof(line) - 1);
    if (n <= 1) break;     // blank line (\r only, after stripping)
  }

  // Read JSON body
  String body = "";
  unsigned long t = millis();
  while ((ec.connected() || ec.available()) && millis() - t < 800) {
    while (ec.available()) body += (char)ec.read();
    yield();
  }
  ec.stop();
  if (!body.length()) return;

  StaticJsonDocument<96> doc;
  if (deserializeJson(doc, body)) return;
  unsigned long ts   = doc["ts"].as<unsigned long>();
  String        type = doc["type"].as<String>();

  if (ts && ts != g_lastEventTs && type != "none") {
    g_lastEventTs   = ts;
    g_lastEventType = type;
    g_labelOn       = true;
    g_labelAt       = millis();
    drawLabel();
    Serial.printf("[event] %s ts=%lu\n", type.c_str(), ts);
  }
}

// 
//  Connect to port 81 and consume the HTTP response headers.
// 
bool connectStream() {
  g_stream.stop();
  Serial.println("[stream] Connecting to port 81...");

  if (!g_stream.connect(CAM_IP, 81)) {
    Serial.println("[stream] TCP connect failed");
    return false;
  }
  g_stream.setTimeout(5000);
  g_stream.print(
    "GET / HTTP/1.1\r\n"
    "Host: " CAM_IP "\r\n"
    "Connection: keep-alive\r\n\r\n"
  );

  // Discard HTTP response headers up to and including the blank line
  char line[128];
  unsigned long t = millis();
  while (millis() - t < 6000) {
    int n = g_stream.readBytesUntil('\n', line, sizeof(line) - 1);
    if (n == 0 && !g_stream.connected()) {
      Serial.println("[stream] Disconnected during header read");
      return false;
    }
    line[n] = 0;
    if (n > 0 && line[n - 1] == '\r') line[--n] = 0;
    if (n == 0) {
      Serial.println("[stream] Stream open");
      return true;
    }
  }

  Serial.println("[stream] Header read timed out");
  g_stream.stop();
  return false;
}

// 
//  MJPEG stream loop  Content-Length parser
//
//  Each MJPEG part:
//    \r\n--boundary\r\n
//    Content-Type: image/jpeg\r\n
//    Content-Length: NNNN\r\n
//    \r\n
//    [NNNN bytes of JPEG]
//
//  Read header lines until blank (extracting Content-Length),
//  then readBytes() exactly that many bytes  readBytes blocks
//  on ESP8266 until the buffer is full or timeout.
// 
void runStream() {
  char line[128];
  g_stream.setTimeout(5000);

  while (g_stream.connected() || g_stream.available()) {

    // Event polling + label expiry between frames
    unsigned long now = millis();
    if (now - g_lastCheck >= EVENT_INTERVAL_MS) {
      g_lastCheck = now;
      checkEvent();
    }
    if (g_labelOn && millis() - g_labelAt >= LABEL_MS) g_labelOn = false;

    //  Read part headers, find Content-Length 
    int contentLen = -1;
    while (true) {
      int n = g_stream.readBytesUntil('\n', line, sizeof(line) - 1);
      if (n == 0 && !g_stream.connected()) goto done;
      line[n] = 0;
      if (n > 0 && line[n - 1] == '\r') line[--n] = 0;
      if (n == 0) break;   // blank line  end of part headers

      if (n > 15 && strncasecmp(line, "Content-Length:", 15) == 0) {
        contentLen = atoi(line + 15);
      }
    }

    // No Content-Length on this "part" (e.g. the leading \r\n
    // before a boundary line)  skip and read the next set.
    if (contentLen <= 0 || contentLen > MAX_JPEG) {
      yield();
      continue;
    }

    //  Read exactly contentLen bytes of JPEG 
    int got = g_stream.readBytes(frameBuf, contentLen);
    if (got != contentLen) {
      Serial.printf("[stream] Short read %d/%d\n", got, contentLen);
      break;
    }

    //  Decode  display 
    if (TJpgDec.drawJpg(0, 0, frameBuf, got) == 0) {
      if (g_labelOn) drawLabel();   // re-stamp banner over fresh frame

      g_frames++;
      now = millis();
      if (now - g_lastFpsMs >= 1000) {
        g_fps       = g_frames;
        g_frames    = 0;
        g_lastFpsMs = now;
        g_live      = true;
        drawBar();
      } else if (!g_live) {
        g_live = true;
        drawBar();
      }
    }

    yield();
  }

done:
  Serial.println("[stream] Stream ended");
  g_live = false;
  drawBar();
}

// 
//  setup()
// 
void setup() {
  Serial.begin(115200);
  Serial.println("\n[boot] Display unit starting");

  tft.init();
  tft.setRotation(3);          // landscape; switch to 1 if image is upside-down
  tft.fillScreen(C_BLACK);

  TJpgDec.setJpgScale(1);      // 1:1  QQVGA 160120 fits 160-wide display
  TJpgDec.setSwapBytes(true);  // RGB565 byte-swap required on ESP8266
  TJpgDec.setCallback(tftBlock);

  tft.setTextColor(C_DGRAY);
  tft.setTextSize(1);
  tft.setCursor(20, 56);
  tft.print("Connecting WiFi...");

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  int tries = 0;
  while (WiFi.status() != WL_CONNECTED && tries < 40) {
    delay(500); Serial.print("."); tries++;
  }

  if (WiFi.status() != WL_CONNECTED) {
    tft.fillScreen(C_BLACK);
    tft.setTextColor(C_RED);
    tft.setCursor(10, 56);
    tft.print("WiFi failed!");
    Serial.println("\n[wifi] Failed");
    while (true) delay(1000);
  }

  Serial.printf("\n[wifi] Connected: %s\n", WiFi.localIP().toString().c_str());

  tft.fillScreen(C_BLACK);
  tft.setTextColor(C_GREEN);
  tft.setCursor(20, 44);
  tft.print("Connected!");
  tft.setTextColor(C_DGRAY);
  tft.setCursor(4, 60);
  tft.print(WiFi.localIP().toString());
  tft.setCursor(4, 76);
  tft.print("Cam: " CAM_IP);
  tft.setCursor(4, 92);
  tft.print("Stream: port 81");
  delay(1500);

  tft.fillScreen(C_BLACK);
  showOffline("Connecting...");
  drawBar();
}

// 
//  loop()
// 
void loop() {
  if (WiFi.status() != WL_CONNECTED) {
    g_live = false;
    g_stream.stop();
    showOffline("WiFi lost...");
    drawBar();
    WiFi.reconnect();
    delay(4000);
    return;
  }

  showOffline("Connecting...");
  drawBar();

  if (connectStream()) {
    runStream();        // blocks until stream drops
  } else {
    delay(3000);        // wait before retry
  }
}

Credits

Rihan Babu
1 project • 0 followers

Comments