Zum Inhalt

webcam_tinyc.tc

webcam_tinyc.tc — Security Camera using TinyC integrated cam driver

Source on GitHub

// ═══════════════════════════════════════════════════════════════════
// webcam_tinyc.tc — Security Camera using TinyC integrated cam driver
// No Tasmota USE_WEBCAM dependency — uses cameraInit + camControl 8-20
//
// Features: MJPEG stream on port 81, motion detection (JPEG size diff),
//           PIR alarm, picture saving, email alerts with up to 4 pictures,
//           timelapse, auto-cleanup (14-day rotation)
//
// Compile: node compile_cli.js webcam_tinyc.tc webcam_tinyc.tcb -DBOARD_DFROBOT
//      or: node compile_cli.js webcam_tinyc.tc webcam_tinyc.tcb -DBOARD_GOOUUU
//      or: node compile_cli.js webcam_tinyc.tc webcam_tinyc.tcb -DBOARD_AITHINKER
//
// Camera capture runs in TaskLoop (VM task thread) — required for
// esp_camera_fb_get() which crashes if called from main thread.
// ═══════════════════════════════════════════════════════════════════
// @defines: -DBOARD_DFROBOT

// ─── Board-specific camera pins ───
// Order: pwdn, reset, xclk, sda, scl, d7, d6, d5, d4, d3, d2, d1, d0, vsync, href, pclk
#ifdef BOARD_DFROBOT
// DFRobot Firebeetle 2 ESP32-S3 (OV3660)
int campins[] = {-1, -1, 5, 8, 9, 4, 6, 7, 14, 17, 21, 18, 16, 1, 2, 15};
#endif

#ifdef BOARD_GOOUUU
// Goouuu ESP32-S3-CAM / ESP32S3_EYE (OV2640)
int campins[] = {-1, -1, 15, 4, 5, 16, 17, 18, 12, 10, 8, 9, 11, 6, 7, 13};
#endif

#ifdef BOARD_AITHINKER
// AI-Thinker ESP32-CAM (OV2640), flash LED on GPIO4
int campins[] = {32, -1, 0, 26, 27, 35, 34, 39, 36, 21, 19, 18, 5, 25, 23, 22};
#endif

// ─── Camera constants ───
#define PIXFORMAT_JPEG      4
#define FRAMESIZE_QVGA      5
#define FRAMESIZE_VGA       8
#define FRAMESIZE_SVGA      9
#define FRAMESIZE_XGA       10
#define FRAMESIZE_SXGA      12
#define FRAMESIZE_UXGA      13

// Sensor parameter IDs for camControl(9, param, value)
#define CAM_VFLIP           0
#define CAM_BRIGHTNESS      1
#define CAM_SATURATION      2
#define CAM_HMIRROR         3
#define CAM_FRAMESIZE       5
#define CAM_QUALITY         6

// Known sensor PIDs
#define OV2640_PID          0x2642
#define OV3660_PID          0x3660
#define OV5640_PID          0x5640

// ─── Hardware config ───
#define PIR_PIN             -1

// ─── Persistent settings (survive reboot) ───
// 'watch' enables changed()/snapshot() intrinsics — no manual shadow copies needed
persist watch int limit;      // software motion alarm threshold (JPEG size diff)
persist watch int pir;        // hardware PIR enabled
persist watch int ma;         // send email on alarm
persist watch int spir;       // software motion detector enabled
persist watch int rpt;        // take picture every N minutes (0=off)
persist watch int ufl;        // use flash LED
persist watch int mtl;        // merge pictures into timelapse mjpeg
persist watch int npic;       // number of pictures per alarm email (1-4)

// ─── Global state ───
int mot;                // current motion value (JPEG size diff)
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 cam_ok;             // camera initialized
int last_size;          // previous frame JPEG size for motion detection
int frame_count;        // total frames captured
int motion_detect;      // total motion detections
int doCapture;          // button: capture from web UI
int doHiRes;            // button: hi-res capture from web UI

char buf[256];
char fnam[80];
char tmp[64];

// ═══════════════════════════════════════════════════════════════════
// Initialize camera with board-specific pins
// ═══════════════════════════════════════════════════════════════════
int initCamera() {
    int ok;

    ok = cameraInit(campins, PIXFORMAT_JPEG, FRAMESIZE_VGA, 12, 0, 0, -1, -1);
    if (ok != 0) {
        print(ok);
        addLog("TCC: camera init FAILED");
        return -1;
    }
    addLog("TCC: camera initialized OK");

    // Check sensor type and apply sensor-specific settings
    int pid = camControl(8, 0, 0);
    sprintf(buf, "TCC: sensor PID = 0x%x", pid);
    addLog(buf);

    if (pid == OV3660_PID) {
        camControl(9, CAM_VFLIP, 1);
        camControl(9, CAM_BRIGHTNESS, 1);
        camControl(9, CAM_SATURATION, -2);
        addLog("TCC: OV3660 settings applied");
    }

    return 0;
}

// ═══════════════════════════════════════════════════════════════════
// Capture to PSRAM slot (1 = stream, 2-4 = pictures)
// Must run in TaskLoop (VM task thread)
// ═══════════════════════════════════════════════════════════════════
int captureToSlot(int slot) {
    int size;
    size = camControl(10, slot, 0);
    if (size <= 0) {
        return -1;
    }
    frame_count = frame_count + 1;
    return size;
}

// ═══════════════════════════════════════════════════════════════════
// Log message with timestamp
// ═══════════════════════════════════════════════════════════════════
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: /PICS/27_2_2026-14_30_05.jpg
// ═══════════════════════════════════════════════════════════════════
void build_pic_name() {
    strcpy(fnam, "/PICS/");
    sprintfAppend(fnam, "%d", tasm_day);
    strcat(fnam, "_");
    sprintfAppend(fnam, "%d", tasm_month);
    strcat(fnam, "_");
    sprintfAppend(fnam, "%d", tasm_year);
    strcat(fnam, "-");
    sprintfAppend(fnam, "%d", tasm_hour);
    strcat(fnam, "_");
    sprintfAppend(fnam, "%d", tasm_minute);
    strcat(fnam, "_");
    sprintfAppend(fnam, "%d", tasm_second);
    strcat(fnam, ".jpg");
}

// ═══════════════════════════════════════════════════════════════════
// Save picture from PSRAM slot 2 to timestamped file
// Capture must have been done in TaskLoop before calling this
// ═══════════════════════════════════════════════════════════════════
void save_pic_from_slot(int slot) {
    build_pic_name();
    int fh = fileOpen(fnam, 'w');
    if (fh >= 0) {
        int written = camControl(11, slot, fh);
        fileClose(fh);
        sprintf(buf, "TCC: saved %d bytes to ", written);
        strcat(buf, fnam);
        addLog(buf);
    } else {
        addLog("TCC: file io error");
    }
}

// ═══════════════════════════════════════════════════════════════════
// Append picture from PSRAM slot to timelapse mjpeg
// ═══════════════════════════════════════════════════════════════════
void save_mjpeg_from_slot(int slot) {
    int fh = fileOpen("/PICS/tlapse.mjpeg", 'a');
    if (fh >= 0) {
        camControl(11, slot, fh);
        fileClose(fh);
    } else {
        addLog("TCC: timelapse file io error");
    }
}

// ═══════════════════════════════════════════════════════════════════
// Delete all pictures in /PICS
// ═══════════════════════════════════════════════════════════════════
void del_folder() {
    log_msg("delete all pictures");
    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() {
    log_msg("delete outdated pictures");

    char ts[24];
    timeStamp(ts);
    timeOffset(ts, -14, 1);
    char dyear[8];
    char dmonth[4];
    char dday[4];
    strToken(dyear, ts, '-', 1);
    strToken(dmonth, ts, '-', 2);
    char rest[16];
    strToken(rest, ts, '-', 3);
    strToken(dday, rest, 'T', 1);
    char cutoff[16];
    strcpy(cutoff, dday);
    strcat(cutoff, "_");
    strcat(cutoff, dmonth);
    strcat(cutoff, "_");
    strcat(cutoff, dyear);

    char entry[48];
    char path[80];
    char fdate[16];
    int dh = fileOpenDir("/PICS");
    if (dh >= 0) {
        while (fileReadDir(dh, entry)) {
            strToken(fdate, entry, '-', 1);
            if (strcmp(fdate, cutoff) == 0) {
                strcpy(path, "/PICS/");
                strcat(path, entry);
                fileDelete(path);
            }
        }
        fileClose(dh);
    }
}

// ═══════════════════════════════════════════════════════════════════
// Send alarm email with up to 4 pictures
// Pictures were captured to slots 1-4 in TaskLoop
// ═══════════════════════════════════════════════════════════════════
int alarm_pics_pending;    // number of alarm pics still to capture
int alarm_pics_captured;   // number captured so far

void send_alarm_email() {
    if (ma == 0) { return; }

    log_msg("motion alarm email");

    char body[64];
    strcpy(body, "Motion alarm<br>");
    mailBody(body);

    // Attach captured pictures (slots 1..npic)
    int i = 1;
    while (i <= npic) {
        mailAttachPic(i);
        // Also save to filesystem
        save_pic_from_slot(i);
        i = i + 1;
    }

    char params[128];
    strcpy(params, "[*:*:*:*:*:your@email.com:Alarm]");
    mailSend(params);
    mailout = 1;
}

// ═══════════════════════════════════════════════════════════════════
// TaskLoop — runs in VM task (Core 1)
// Camera capture MUST run here, not in EverySecond
// ═══════════════════════════════════════════════════════════════════
void TaskLoop() {
    if (cam_ok != 1) {
        delay(100);
        return;
    }

    // Continuous capture to slot 1 for streaming
    int size = captureToSlot(1);

    // Motion detection via JPEG file size comparison
    if (last_size > 0 && size > 0) {
        int diff = size - last_size;
        if (diff < 0) { diff = 0 - diff; }
        mot = diff;
        if (diff > limit) {
            motion_detect = motion_detect + 1;
        }
    }
    if (size > 0) { last_size = size; }

    // Alarm picture capture (triggered from EverySecond)
    if (alarm_pics_pending > 0) {
        flash_on();
        int slot = alarm_pics_captured + 1;
        captureToSlot(slot);
        alarm_pics_captured = alarm_pics_captured + 1;
        alarm_pics_pending = alarm_pics_pending - 1;
        flash_off();

        if (alarm_pics_pending == 0) {
            // All alarm pics captured — send email from EverySecond
            send_alarm_email();
        }
    }

    // Single picture capture via web button
    if (doCapture == 1) {
        doCapture = 0;
        flash_on();
        captureToSlot(2);
        save_pic_from_slot(2);
        flash_off();
    }

    // Hi-res capture via web button
    if (doHiRes == 1) {
        doHiRes = 0;
        flash_on();
        camControl(9, CAM_FRAMESIZE, FRAMESIZE_UXGA);
        delay(500);
        captureToSlot(2);
        save_pic_from_slot(2);
        camControl(9, CAM_FRAMESIZE, FRAMESIZE_VGA);
        flash_off();
    }

    // Periodic capture
    if (take_pic == 2) {
        take_pic = 0;
        flash_on();
        captureToSlot(2);
        if (mtl > 0) {
            save_mjpeg_from_slot(2);
        } else {
            save_pic_from_slot(2);
        }
        flash_off();
    }

    delay(100);
}

// ═══════════════════════════════════════════════════════════════════
// EverySecond — main thread, lightweight checks only
// ═══════════════════════════════════════════════════════════════════
void EverySecond() {
    // Delete old pictures at midnight
    if (tasm_hour == 0 && last_hour != 0) {
        del_old();
    }
    last_hour = tasm_hour;

    // Check PIR / software motion alarm (one-shot: fires once, re-arms when clear)
    if ((pir > 0 && PIR_PIN >= 0 && digitalRead(PIR_PIN) == 0) ||
        (spir > 0 && mot > limit)) {
        if (alarm_active == 0) {
            alarm_active = 1;
            log_msg("motion alarm");
            // Trigger alarm picture capture in TaskLoop
            alarm_pics_pending = npic;
            alarm_pics_captured = 0;
        }
    } else {
        alarm_active = 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;
            take_pic = 2;  // signal TaskLoop to capture + save
        }
    } else {
        rpt_timer = 0;
    }

    // Delete all pictures on button press
    if (del_all > 0) {
        del_all = 0;
        del_folder();
    }

    // Auto-save persist variables when any setting changes
    if (changed(limit) || changed(pir) || changed(ma) ||
        changed(spir) || changed(rpt) || changed(ufl) ||
        changed(mtl) || changed(npic)) {
        snapshot(limit); snapshot(pir); snapshot(ma);
        snapshot(spir); snapshot(rpt); snapshot(ufl);
        snapshot(mtl); snapshot(npic);
        saveVars();
    }
}

// ═══════════════════════════════════════════════════════════════════
// WebCall — sensor values (AJAX-refreshed area)
// Stream image is on main page via FUNC_WEB_ADD_MAIN_BUTTON
// ═══════════════════════════════════════════════════════════════════
void WebCall() {
    webSend("{s}<b style='color:cyan'>Camera</b>{m}{e}");
    sprintf(buf, "{s}Frames{m}%d{e}", frame_count);
    webSend(buf);
    sprintf(buf, "{s}Motion{m}%d{e}", mot);
    webSend(buf);
    sprintf(buf, "{s}Detected{m}%d{e}", motion_detect);
    webSend(buf);
    sprintf(buf, "{s}Heap{m}%d kB{e}", tasm_heap / 1000);
    webSend(buf);
}

// ═══════════════════════════════════════════════════════════════════
// WebUI — controls on Tasmota main page
// Note: stream image on main page is provided by camControl(15,1,0)
//       stream server — no WebPage() needed
// ═══════════════════════════════════════════════════════════════════
void WebUI() {
    if (PIR_PIN >= 0) {
        webCheckbox(pir, "Hardware PIR");
    }
    webCheckbox(spir, "Software motion detector");
    webNumber(limit, 50, 10000, "Motion alarm threshold");
    webCheckbox(ma, "Send email on alarm");
    webNumber(npic, 1, 4, "Pictures per alarm (1-4)");
    webCheckbox(ufl, "Use flash LED");
    webNumber(rpt, 0, 60, "Take picture every N minutes");
    webCheckbox(mtl, "Merge into tlapse.mjpeg");
    webButton(doCapture, "Take Picture");
    webButton(doHiRes, "Hi-Res Capture");
    webButton(del_all, "Delete all pictures");
}

// ═══════════════════════════════════════════════════════════════════
// JSON output for integration
// ═══════════════════════════════════════════════════════════════════
void JsonCall() {
    sprintf(buf, ",\"TinyCam\":{\"Motion\":%d,\"Detected\":%d,\"Frames\":%d}", mot, motion_detect, frame_count);
    responseAppend(buf);
}

// ═══════════════════════════════════════════════════════════════════
// OnExit — clean up camera resources
// ═══════════════════════════════════════════════════════════════════
void OnExit() {
    camControl(15, 0, 0);      // stop stream server
    camControl(18, 0, 0);      // free motion buffers
    camControl(12, 0, 0);      // free all PSRAM slots
    camControl(13, 0, 0);      // deinit camera
    addLog("TCC: camera shut down");
}

// ═══════════════════════════════════════════════════════════════════
// MAIN — initialization
// ═══════════════════════════════════════════════════════════════════
int main() {
    mot = 0;
    mailout = 0;
    alarm_active = 1;       // suppress alarm until first motion-clear
    take_pic = 0;
    doCapture = 0;
    doHiRes = 0;
    del_all = 0;
    stream_on = 0;
    last_hour = -1;
    cam_ok = 0;
    last_size = 0;
    frame_count = 0;
    motion_detect = 0;
    alarm_pics_pending = 0;
    alarm_pics_captured = 0;

    // Default settings if not persisted
    if (limit == 0) { limit = 400; }
    if (npic == 0) { npic = 1; }
    if (npic > 4) { npic = 4; }

    // Snapshot all watch vars so changed() starts clean
    snapshot(limit); snapshot(pir); snapshot(ma);
    snapshot(spir); snapshot(rpt); snapshot(ufl);
    snapshot(mtl); snapshot(npic);

    // Sanitize corrupted persist values
    if (pir > 1 || ma > 1 || spir > 1 || ufl > 1 || mtl > 1) {
        pir = 0; ma = 0; spir = 0; ufl = 0; mtl = 0;
        saveVars();
    }

    rpt_timer = rpt * 60;

    // PIR input pin (only if configured, PIR_PIN=-1 means no hardware PIR)
    if (PIR_PIN >= 0) {
        pinMode(PIR_PIN, 0);
    }

    // Create pictures folder
    if (!fileExists("/PICS")) {
        fileMkdir("/PICS");
    }

    // Initialize camera
    int ok = initCamera();
    if (ok != 0) {
        return -1;
    }
    cam_ok = 1;

    // First test capture
    int size = captureToSlot(1);
    sprintf(buf, "TCC: camera ready, first frame: %d bytes, heap: %d", size, tasm_heap);
    addLog(buf);

    // Start MJPEG stream server on port 81
    // (deferred automatically if WiFi not ready yet — safe for autoexec)
    camControl(15, 1, 0);

    log_msg("camera booted");

    return 0;
}