/*
* ============================================================
* 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);
}
Comments