Skip to content

webcam.tc

Webcam Security Camera Script

Source on GitHub

// @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>&#x1F4F7; Camera</h3>");

    webSend("<b>&#x1F5BC;&#xFE0F; 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>&#x1F6B6; 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>&#x2709;&#xFE0F; Alarm</b>");
    webCheckbox(ma, "Send email on alarm");
    webCheckbox(ufl, "Use flash LED");

    webSend("<hr><b>&#x23F1;&#xFE0F; 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>&#x1F4F8; 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;
}