webcam.tc¶
Webcam Security Camera Script
// @name: Webcam Security (USE_WEBCAM)
// Webcam Security Camera Script
// ESP32-CAM with motion detection, PIR alarm, picture saving, email alerts
// Features: Hardware PIR, software motion detector, timelapse, auto-cleanup
// Converted from Tasmota Scripter webcam script
//
// Assumes: LED flasher on GPIO relay (Power1), PIR sensor on GPIO 3
// Camera: QVGA resolution, continuous stream on port 81
// ─── Hardware config ───
#define PIR_PIN 3
#define CAM_QVGA 6
// ─── Resolution picker ───
// webPulldown hands back a 0-based INDEX, the driver wants a framesize.
// ⚠️ NOT the classic framesize_t numbering. The esp32-camera branch in this
// tree carries two extra entries (128X128 at 2, 320X320 at 7), so QVGA is 6
// and VGA is 10 — see lib/lib_div/esp32-camera/driver/include/sensor.h. The
// numbers below were checked against the real picture (SOF marker of
// http://<ip>:81/cam.jpg), not copied from a table.
#define RES_COUNT 10
#define RES_FIRST 6 // index 0 = QVGA 320x240
#define RES_DEFAULT 4 // index 4 = VGA 640x480
// ─── Persistent settings (survive reboot) ───
persist int limit; // software motion alarm threshold
persist int pir; // hardware PIR enabled
persist int ma; // send email on alarm
persist int spir; // software motion detector enabled
persist int rpt; // take picture every N minutes (0=off)
persist int ufl; // use flash LED
persist int mtl; // merge pictures into timelapse mjpeg
persist int res; // resolution, INDEX into the pulldown (not a framesize)
persist int res_seen; // ⚠️ 1 once `res` was ever stored. Index 0 (QVGA) is a
// legal choice, so "0 means unset" would make QVGA
// impossible to pick — it would snap back on restart.
// ─── Global state ───
int mot; // current motion value
int bri; // picture brightness
int cam_w; // camera width
int cam_h; // camera height
int alarm_active; // 1 = alarm triggered, wait for motion to stop
int mailout; // mail sent flag
int take_pic; // button: take picture now
int del_all; // button: delete all pictures
int stream_on; // stream started flag
int last_hour; // for midnight detection
int rpt_timer; // countdown for periodic capture (seconds)
int sv_limit; // shadow copies for change detection
int sv_pir;
int sv_ma;
int sv_spir;
int sv_rpt;
int sv_ufl;
int sv_mtl;
int sv_res;
char buf[256];
char fnam[80];
// ─── Log message with timestamp ───
// Index of the pulldown -> framesize the driver understands.
// ⚠️ Clamped: `res` comes from persistent storage, and a stale or corrupted
// value would otherwise configure the sensor with nonsense.
int res_framesize() {
int i = res;
if (i < 0) { i = RES_DEFAULT; }
if (i >= RES_COUNT) { i = RES_DEFAULT; }
return RES_FIRST + i;
}
void log_msg(char msg[]) {
char line[128];
timeStamp(line);
strcat(line, " - ");
strcat(line, msg);
fileLog("/log.txt", line, 8192);
}
// ─── Flash LED helpers ───
void flash_on() {
if (ufl > 0) {
tasm_power = 1;
delay(150);
}
}
void flash_off() {
if (ufl > 0) {
tasm_power = 0;
}
}
// ─── Build timestamped filename into fnam ───
// Result: /PICS/27_2_2026-14_30_05.jpg
void build_pic_name() {
sprintf(fnam, "/PICS/%d_%d_%d-%d_%d_%d.jpg", tasm_day, tasm_month, tasm_year, tasm_hour, tasm_minute, tasm_second);
}
// ─── Save picture to /PICS/day_month_year-hours_mins_secs.jpg ───
void save_pic() {
flash_on();
// Capture to RAM buffer 1
camControl(1, 1, 0);
build_pic_name();
int fh = fileOpen(fnam, 'w');
if (fh >= 0) {
// savePic: write RAM buffer 1 to file handle
camControl(7, 1, fh);
fileClose(fh);
} else {
print("file io error\n");
}
flash_off();
}
// ─── Append picture to timelapse mjpeg file ───
void save_mjpeg() {
flash_on();
camControl(1, 1, 0);
int fh = fileOpen("/PICS/tlapse.mjpeg", 'a');
if (fh >= 0) {
camControl(7, 1, fh);
fileClose(fh);
} else {
print("file io error\n");
}
flash_off();
}
// ─── Delete all pictures in /PICS ───
void del_folder() {
char msg[32];
strcpy(msg, "delete all pictures");
log_msg(msg);
char entry[48];
char path[80];
int dh = fileOpenDir("/PICS");
if (dh >= 0) {
while (fileReadDir(dh, entry)) {
strcpy(path, "/PICS/");
strcat(path, entry);
fileDelete(path);
}
fileClose(dh);
}
}
// ─── Delete outdated files (14 days back) ───
void del_old() {
char msg[32];
strcpy(msg, "delete outdated pictures");
log_msg(msg);
// Build cutoff date string: "day_month_year"
char ts[24];
timeStamp(ts);
timeOffset(ts, -14, 1);
// ts = "2026-02-13T00:00:00" — extract date parts
char dyear[8];
char dmonth[4];
char dday[4];
strToken(dyear, ts, '-', 1);
strToken(dmonth, ts, '-', 2);
// day is before 'T'
char rest[16];
strToken(rest, ts, '-', 3);
strToken(dday, rest, 'T', 1);
// Build cutoff prefix: "13_2_2026"
char cutoff[16];
strcpy(cutoff, dday);
strcat(cutoff, "_");
strcat(cutoff, dmonth);
strcat(cutoff, "_");
strcat(cutoff, dyear);
// Iterate directory and delete matching files
char entry[48];
char path[80];
char fdate[16];
int dh = fileOpenDir("/PICS");
if (dh >= 0) {
while (fileReadDir(dh, entry)) {
// Extract date prefix from filename (before first '-')
strToken(fdate, entry, '-', 1);
if (strcmp(fdate, cutoff) == 0) {
strcpy(path, "/PICS/");
strcat(path, entry);
fileDelete(path);
}
}
fileClose(dh);
}
}
// ─── Send alarm email with picture ───
void send_alarm() {
char msg[32];
strcpy(msg, "motion alarm");
log_msg(msg);
// Capture and save picture
flash_on();
camControl(1, 1, 0);
build_pic_name();
int fh = fileOpen(fnam, 'w');
if (fh >= 0) {
camControl(7, 1, fh);
fileClose(fh);
}
flash_off();
if (ma > 0) {
char body[64];
strcpy(body, "Motion alarm camera 1<br>");
mailBody(body);
mailAttachPic(1);
char params[128];
strcpy(params, "[*:*:*:*:*:your@email.com:Alarm]");
mailSend(params);
mailout = 1;
}
}
// ─── Every Second callback ───
void EverySecond() {
// Start stream when WiFi connects
if (tasm_wifi > 0 && stream_on == 0) {
camControl(5, 1, 0);
stream_on = 1;
}
// Delete old pictures at midnight (hour transition to 0)
if (tasm_hour == 0 && last_hour != 0) {
del_old();
}
last_hour = tasm_hour;
// Read motion value and brightness
mot = camControl(6, -1, 0);
bri = camControl(6, -2, 0);
// Check PIR / software motion alarm (one-shot: fires once, re-arms when motion stops)
if ((pir > 0 && digitalRead(PIR_PIN) == 0) ||
(spir > 0 && mot > limit)) {
if (alarm_active == 0) {
alarm_active = 1;
send_alarm();
}
} else {
alarm_active = 0;
}
// Take picture on button press
if (take_pic > 0) {
save_pic();
take_pic = 0;
}
// Periodic picture taking (every rpt minutes)
if (rpt > 0) {
if (rpt_timer > 0) {
rpt_timer = rpt_timer - 1;
}
if (rpt_timer == 0) {
rpt_timer = rpt * 60;
if (mtl > 0) {
save_mjpeg();
} else {
save_pic();
}
}
} else {
rpt_timer = 0;
}
// Delete all pictures on button press
if (del_all > 0) {
del_all = 0;
del_folder();
}
// Resolution changed: the Tasmota driver is re-initialised at the new
// framesize. ⚠️ The stream has to go down first — it hands out frames from
// the buffer that is being reallocated underneath it — and the motion
// detector has to be re-armed afterwards, because the re-init drops its
// reference picture.
if (res != sv_res) {
sv_res = res;
camControl(5, 0, 0); // stop stream
camControl(0, res_framesize(), 0); // re-init at the new size
cam_w = camControl(3, 0, 0);
cam_h = camControl(4, 0, 0);
camControl(6, 1000, 0); // motion detector back on
camControl(5, 1, 0); // stream back on
stream_on = 1;
saveVars();
addLog("webcam: resolution now %d (%dx%d)", res_framesize(), cam_w, cam_h);
}
// Auto-save persist variables when settings change
if (limit != sv_limit || pir != sv_pir || ma != sv_ma ||
spir != sv_spir || rpt != sv_rpt || ufl != sv_ufl || mtl != sv_mtl) {
sv_limit = limit; sv_pir = pir; sv_ma = ma;
sv_spir = spir; sv_rpt = rpt; sv_ufl = ufl; sv_mtl = mtl;
saveVars();
}
}
// ─── Web UI: sensor values (re-rendered on every sensor refresh) ───
void WebCall() {
sprintf(buf, "{s}Motion{m}%d{e}", mot);
webSend(buf);
sprintf(buf, "{s}Brightness{m}%d{e}", bri);
webSend(buf);
sprintf(buf, "{s}Heap{m}%d kB{e}", tasm_heap / 1000);
webSend(buf);
}
// ─── Web Page: camera stream (rendered once on page load) ───
// Chrome: MJPEG stream on port 81 (smooth video)
// Safari: periodic snapshot refresh (workaround for multipart/x-mixed-replace bug)
void WebPage() {
sprintf(buf, "<br><img id=\"cs\" style=\"width:%dpx;height:%dpx\">", cam_w, cam_h);
webSend(buf);
webSend("<script>var cs=document.getElementById(\"cs\");");
webSend("var h=\"http://\"+location.hostname;");
webSend("if(/^((?!chrome|android).)*safari/i.test(navigator.userAgent)){");
webSend("setInterval(function(){cs.src=h+\"/snapshot.jpg?\"+Date.now()},200)");
webSend("}else{cs.src=h+\":81/stream\"}</script>");
webSend("<br><br><center>webcam office</center>");
}
// ─── Web UI: controls ───
void WebUI() {
// Optik wie bei den SML-Treibern (common/sml_descriptor.tc): eine Karte mit
// Überschriften, kleiner Erklärzeile und fester Breite.
// ⚠️ Die Breite gehört an `body>div`. Tasmotas Rahmen ist ein inline-block
// mit min-width:340px und wächst mit seinem breitesten Kind -- ohne die
// feste Breite zieht ein langer Klappmenü-Eintrag die Seite auseinander.
webSend("<style>body>div{width:480px;max-width:96vw;box-sizing:border-box}.cam-p{max-width:100%;box-sizing:border-box;margin:12px auto;background:#f0f0f0;color:#000;padding:16px;border:2px solid #ccc;border-radius:6px;text-align:left}.cam-p h3{margin:0 0 8px}.cam-p hr{border:0;border-top:1px solid #bbb;margin:12px 0}.cam-p b{display:inline-block;margin-bottom:4px;font-size:13px}.cam-p div{margin:6px 0}.cam-p .hint{font-size:9px;color:#555;line-height:1.4}</style>");
webSend("<div class='cam-p'><h3>📷 Camera</h3>");
webSend("<b>🖼️ Picture</b>");
webPulldown(res, "Resolution", "QVGA 320x240|320x320|CIF 400x296|HVGA 480x320|VGA 640x480|SVGA 800x600|XGA 1024x768|HD 1280x720|SXGA 1280x1024|UXGA 1600x1200");
sprintf(buf, "<div class='hint'>Sensor reports <b>%dx%d</b>. Changing this re-initialises the driver: the stream drops for a moment and the motion detector starts over. Larger pictures cost PSRAM and frame rate; for motion detection VGA is plenty.</div>", cam_w, cam_h);
webSend(buf);
webSend("<hr><b>🚶 Motion</b>");
webCheckbox(spir, "Software motion detector");
webNumber(limit, 50, 10000, "Software alarm limit");
webCheckbox(pir, "Hardware PIR");
webSend("<div class='hint'>The software detector works on the picture itself -- after a resolution change the limit usually has to be adjusted.</div>");
webSend("<hr><b>✉️ Alarm</b>");
webCheckbox(ma, "Send email on alarm");
webCheckbox(ufl, "Use flash LED");
webSend("<hr><b>⏱️ Timelapse</b>");
webNumber(rpt, 0, 60, "Take picture every N minutes");
webCheckbox(mtl, "Merge into tlapse.mjpeg file");
webSend("<div class='hint'>0 minutes = off.</div>");
webSend("<hr><b>📸 Now</b>");
webButton(take_pic, "Take a picture|saved");
webButton(del_all, "Delete all pictures|deleted");
webSend("<div style='text-align:center;font-size:10px;color:#777;margin-top:10px'><b>webcam.tc</b></div></div>");
}
// ─── JSON output ───
void JsonCall() {
sprintf(buf, ",\"Webcam\":{\"Motion\":%d,\"Brightness\":%d}", mot, bri);
responseAppend(buf);
}
// ─── Cleanup on exit ───
void OnExit() {
if (stream_on) {
camControl(5, 0, 0);
stream_on = 0;
}
}
// ─── Main: initialization ───
int main() {
// ⚠️ Ohne webPageLabel() erscheint kein Knopf zu diesen Einstellungen:
// ein blosses WebUI() bekam frueher automatisch einen generischen
// "TinyC UI"-Knopf, den gibt es nicht mehr (er loeste auf den falschen
// Slot auf und antwortete 503). Geprueft von
// scripts/webui_knopf_pruefen.py.
webPageLabel(0, "Camera Setup");
mot = 0;
bri = 0;
mailout = 0;
alarm_active = 1; // suppress alarm until first motion-clear
rpt_timer = rpt * 60; // first capture after full interval
take_pic = 0;
del_all = 0;
stream_on = 0;
last_hour = -1;
// Default alarm limit if not persisted
if (limit == 0) {
limit = 1000;
}
// Initialize shadow copies for change detection
sv_limit = limit; sv_pir = pir; sv_ma = ma;
sv_spir = spir; sv_rpt = rpt; sv_ufl = ufl; sv_mtl = mtl;
// Delete stale persist file if values were corrupted (old siva bug wrote ASCII not int)
if (pir > 1 || ma > 1 || spir > 1 || ufl > 1 || mtl > 1) {
pir = 0; ma = 0; spir = 0; ufl = 0; mtl = 0;
saveVars();
}
if (res_seen == 0) { res = RES_DEFAULT; res_seen = 1; }
sv_res = res;
// ⚠️ Start with the STORED resolution, not a hard-wired one: otherwise the
// sensor is back at QVGA after every restart while the pulldown shows
// something else.
camControl(0, res_framesize(), 0);
cam_w = camControl(3, 0, 0);
cam_h = camControl(4, 0, 0);
// Start motion detector, check every 1000ms
camControl(6, 1000, 0);
// Create pictures folder
if (!fileExists("/PICS")) {
fileMkdir("/PICS");
}
// PIR input pin
pinMode(PIR_PIN, 0);
char msg[16];
strcpy(msg, "booting");
log_msg(msg);
// Startup sound
audioPlay("/Startup.mp3");
sprintf(buf, "Webcam init OK, %d", cam_w);
print(buf);
sprintf(buf, "x%d\n", cam_h);
print(buf);
return 0;
}