webcam_tinyc.tc¶
webcam_tinyc.tc — Security Camera using TinyC integrated cam driver
// ═══════════════════════════════════════════════════════════════════
// 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;
}