IoTLabs

Nghiên cứu, Sáng tạo và Thử nghiệm

ESP32-S3-DevKitC N16R8 CAM: Bài 11 — Chụp ảnh lưu microSD – đặt tên file, timestamp, chống full thẻ

Giới thiệu

Bài 11 hướng dẫn bạn chụp ảnh từ camera trên ESP32-S3-DevKitC N16R8 CAM và lưu xuống thẻ microSD một cách ổn định, dễ mở rộng, chạy lâu. Bạn sẽ có một workflow hoàn chỉnh: khởi tạo camera + microSD, chụp ảnh, đặt tên file theo timestamp, tạo thư mục theo ngày, và có cơ chế chống full thẻ (giới hạn dung lượng / xoá file cũ / vòng tròn).

Mục tiêu bài học

Sau bài này, bạn sẽ:

  • Hiểu 2 cách lưu thẻ: SD (SPI)SD_MMC (trên board CAM thường là SD_MMC)
  • Chụp ảnh JPEG bằng esp_camera và ghi file lên microSD
  • Đặt tên file khoa học: theo timestamp + thư mục theo ngày
  • Tự tạo timestamp khi chưa có RTC/NTP (uptime-based) và nâng cấp lên NTP
  • Chống full thẻ: giới hạn dung lượng, đếm file, xoá file cũ nhất
  • Có code mẫu hoàn chỉnh, để bạn gắn vào dự án Bài 13/14

Điều kiện trước khi làm

  • Board: ESP32-S3-DevKitC N16R8 CAM (có PSRAM)
  • Camera hoạt động được (bạn nên đã test qua Bài 10)
  • Thẻ microSD: 8GB–32GB, format FAT32 (để ổn định)
  • Nguồn: cáp USB chất lượng, tránh sụt áp khi camera + ghi thẻ

Lưu ý: Nếu bạn stream camera và đồng thời ghi thẻ liên tục, hệ thống dễ bị thiếu heap/PSRAM hoặc sụt áp. Bài này tập trung vào chụp ảnh và lưu thẻ (snapshot workflow).

1. Tổng quan luồng hoạt động

  1. Init microSD
  2. Init camera
  3. Khi cần chụp ảnh:
  • Lấy frame JPEG (camera_fb_t* fb)
  • Tạo đường dẫn file (folder/ngày + timestamp)
  • Ghi file ra SD
  • Trả frame về cho driver (esp_camera_fb_return(fb))
  1. Nếu sắp full:
  • Xoá file cũ nhất hoặc giới hạn dung lượng

2. Chọn chế độ microSD: SD vs SD_MMC

SD (SPI)

  • Dùng các chân SPI (MOSI/MISO/SCK/CS)
  • Có thể linh động mapping GPIO
  • Tốc độ tuỳ thuộc bus SPI

SD_MMC (4-bit / 1-bit)

  • Thường là kết nối “on-board” trên board CAM
  • Dùng interface SD/MMC của ESP32
  • Đơn giản, ổn định hơn cho board CAM

Gợi ý: Trên board ESP32-S3 CAM (có khe thẻ trên PCB), bạn nên ưu tiên SD_MMC nếu board hỗ trợ.


Quy ước đặt tên file và thư mục

Để dễ quản lý và chống trùng tên:

  • Thư mục theo ngày: /cam/2025-12-25/
  • Tên file: IMG_20251225_221530.jpg

Nếu chưa có giờ hệ thống, tạm thời dùng uptime:

  • IMG_up_00012345.jpg (12345 giây)

Timestamp: 2 cách (đơn giản → chuẩn)

Cách 1: Uptime timestamp (không cần Internet)

  • Dùng millis() quy đổi ra giây
  • Phù hợp logger nhanh, nhưng không phải giờ thực

Cách 2: NTP timestamp (có WiFi)

  • Kết nối WiFi
  • Lấy giờ từ NTP (configTime)
  • Đặt tên file theo giờ thực

Bài 13 sẽ dùng NTP để tạo “nhật ký camera” chuẩn giờ.


Cơ chế chống full thẻ

Bạn có 3 chiến lược (chọn 1, hoặc kết hợp):

Chiến lược A: Giới hạn dung lượng (recommended)

  • Ví dụ: chỉ dùng tối đa 80% dung lượng thẻ
  • Nếu vượt: xoá file cũ nhất

Chiến lược B: Giới hạn số lượng file

  • Ví dụ: tối đa 2000 ảnh
  • Nếu vượt: xoá 50 ảnh cũ nhất

Chiến lược C: Vòng tròn (circular)

  • Đặt tên theo index: 000001.jpg010000.jpg
  • Ghi đè khi quay vòng

Trong tutorial, mình làm Chiến lược B (dễ hiểu) và gợi ý mở rộng sang A.

3. Code ví dụ hoàn chỉnh (Camera + SD_MMC + chụp và lưu)

Lưu ý quan trọng: Pin mapping camera có thể khác tuỳ board. Hãy dùng mapping đúng với board của bạn (theo pinout/nhãn hãng). Đoạn mapping dưới đây là mẫu.

#include <Arduino.h>
#include "esp_camera.h"
#include "FS.h"
#include "SD_MMC.h"
#include <WiFi.h>
#include <time.h>

// ================== WIFI (optional for NTP) ==================
// Neu ban muon timestamp chuan gio, hay dien WiFi.
// Neu khong can, bo qua WiFi va se dung uptime.
const char* WIFI_SSID = "";
const char* WIFI_PASS = "";

// ================== STORAGE CONFIG ==================
static const char* BASE_DIR = "/cam";
static const uint32_t MAX_FILES = 2000;      // Chien luoc B
static const uint32_t DELETE_BATCH = 50;     // Xoa 50 file moi lan vuot nguong

// ================== CAMERA PIN MAP (SAMPLE) ==================
// !!! HAY DOI THEO BOARD CUA BAN !!!
// Neu ban dang dung board ESP32-S3-CAM (24-pin), thuong se giong cac pin map tu nha san xuat.
// Khong co 1 mapping duy nhat cho tat ca board.
#define PWDN_GPIO_NUM   -1
#define RESET_GPIO_NUM  -1
#define XCLK_GPIO_NUM   15
#define SIOD_GPIO_NUM    4
#define SIOC_GPIO_NUM    5

#define Y9_GPIO_NUM     16
#define Y8_GPIO_NUM     17
#define Y7_GPIO_NUM     18
#define Y6_GPIO_NUM     12
#define Y5_GPIO_NUM     10
#define Y4_GPIO_NUM      8
#define Y3_GPIO_NUM      9
#define Y2_GPIO_NUM     11

#define VSYNC_GPIO_NUM   6
#define HREF_GPIO_NUM    7
#define PCLK_GPIO_NUM   13

// ================== UTILS ==================
String twoDigits(int v) {
  if (v < 10) return "0" + String(v);
  return String(v);
}

bool isTimeValid() {
  time_t now = time(nullptr);
  return now > 1700000000; // moc tam (2023+). Neu nho hon thi coi nhu chua sync NTP.
}

String getDateFolder() {
  if (!isTimeValid()) {
    return String("up-") + String(millis() / 1000);
  }
  struct tm t;
  localtime_r(&now, &t);
  String y = String(t.tm_year + 1900);
  String m = twoDigits(t.tm_mon + 1);
  String d = twoDigits(t.tm_mday);
  return y + "-" + m + "-" + d;
}

String getFileNameJpg() {
  if (!isTimeValid()) {
    // Uptime-based
    uint32_t s = millis() / 1000;
    return String("IMG_up_") + String(s) + ".jpg";
  }
  time_t now = time(nullptr);
  struct tm t;
  localtime_r(&now, &t);
  String y = String(t.tm_year + 1900);
  String mo = twoDigits(t.tm_mon + 1);
  String d = twoDigits(t.tm_mday);
  String hh = twoDigits(t.tm_hour);
  String mm = twoDigits(t.tm_min);
  String ss = twoDigits(t.tm_sec);
  return "IMG_" + y + mo + d + "_" + hh + mm + ss + ".jpg";
}

bool ensureDir(fs::FS &fs, const String &path) {
  if (fs.exists(path)) return true;
  return fs.mkdir(path);
}

// Dem file jpg trong folder (don gian)
uint32_t countJpgInDir(fs::FS &fs, const String &dirPath) {
  File dir = fs.open(dirPath);
  if (!dir || !dir.isDirectory()) return 0;

  uint32_t count = 0;
  File f = dir.openNextFile();
  while (f) {
    if (!f.isDirectory()) {
      String n = f.name();
      n.toLowerCase();
      if (n.endsWith(".jpg")) count++;
    }
    f = dir.openNextFile();
  }
  return count;
}

// Xoa file cu nhat trong folder (duyet theo mtime neu co, neu khong thi theo ten)
// Implement don gian: thu thap danh sach ten file -> sort -> xoa N file dau
// (Voi Arduino co gioi han RAM, nen ta lam don gian: xoa theo ten tang dan neu dat ten theo timestamp)
uint32_t deleteOldestBatch(fs::FS &fs, const String &dirPath, uint32_t batch) {
  File dir = fs.open(dirPath);
  if (!dir || !dir.isDirectory()) return 0;

  // Thu thap ten file (gioi han)
  const uint32_t LIMIT = 3000;
  static String names[256]; // tranh cap phat dong. Neu folder qua nhieu, chi lay 256 file dau.
  uint32_t n = 0;

  File f = dir.openNextFile();
  while (f && n < 256) {
    if (!f.isDirectory()) {
      String name = String(f.name());
      String lower = name;
      lower.toLowerCase();
      if (lower.endsWith(".jpg")) {
        names[n++] = name;
      }
    }
    f = dir.openNextFile();
  }

  // Sort theo ten (IMG_YYYYMMDD_HHMMSS.jpg) se tu dong theo thu tu thoi gian
  for (uint32_t i = 0; i < n; i++) {
    for (uint32_t j = i + 1; j < n; j++) {
      if (names[j] < names[i]) {
        String tmp = names[i];
        names[i] = names[j];
        names[j] = tmp;
      }
    }
  }

  uint32_t deleted = 0;
  for (uint32_t i = 0; i < n && deleted < batch; i++) {
    if (fs.remove(names[i])) deleted++;
  }
  return deleted;
}

bool initSDMMC() {
  // Tranh loi mount: doi mot chut
  delay(200);

  if (!SD_MMC.begin("/sdcard", true /*mode1bit*/)) {
    // Thu lai 4-bit neu board ho tro
    if (!SD_MMC.begin("/sdcard", false /*4bit*/)) {
      Serial.println("[SD] SD_MMC mount failed");
      return false;
    }
  }

  uint8_t cardType = SD_MMC.cardType();
  if (cardType == CARD_NONE) {
    Serial.println("[SD] No SD card attached");
    return false;
  }

  Serial.print("[SD] Card Type: ");
  if (cardType == CARD_MMC) Serial.println("MMC");
  else if (cardType == CARD_SD) Serial.println("SDSC");
  else if (cardType == CARD_SDHC) Serial.println("SDHC");
  else Serial.println("UNKNOWN");

  uint64_t cardSize = SD_MMC.cardSize() / (1024ULL * 1024ULL);
  Serial.printf("[SD] Card Size: %llu MB\n", cardSize);

  return true;
}

bool initCamera() {
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer   = LEDC_TIMER_0;

  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;

  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href  = HREF_GPIO_NUM;

  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;

  config.pin_pwdn  = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;

  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;

  // Neu co PSRAM, tang chat luong/size
  if (psramFound()) {
    config.frame_size   = FRAMESIZE_SVGA;
    config.jpeg_quality = 12;
    config.fb_count     = 2;
  } else {
    config.frame_size   = FRAMESIZE_VGA;
    config.jpeg_quality = 15;
    config.fb_count     = 1;
  }

  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("[CAM] init failed: 0x%x\n", err);
    return false;
  }

  sensor_t *s = esp_camera_sensor_get();
  // Co the tuy chinh sensor o day neu can
  // s->set_framesize(s, FRAMESIZE_SVGA);

  Serial.println("[CAM] init OK");
  return true;
}

void trySyncTimeNTP() {
  if (String(WIFI_SSID).length() == 0) {
    Serial.println("[TIME] Skip NTP (no WiFi config)");
    return;
  }

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("[WIFI] Connecting");

  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < 12000) {
    delay(300);
    Serial.print(".");
  }
  Serial.println();

  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("[WIFI] Not connected -> use uptime timestamp");
    return;
  }

  Serial.printf("[WIFI] Connected: %s\n", WiFi.localIP().toString().c_str());

  // GMT+7 Vietnam
  configTime(7 * 3600, 0, "pool.ntp.org", "time.nist.gov");

  Serial.print("[TIME] Syncing NTP");
  uint32_t t1 = millis();
  while (!isTimeValid() && millis() - t1 < 10000) {
    delay(300);
    Serial.print(".");
  }
  Serial.println();

  if (isTimeValid()) {
    Serial.println("[TIME] NTP synced OK");
  } else {
    Serial.println("[TIME] NTP sync failed -> use uptime timestamp");
  }
}

bool savePhotoToSD() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) {
    Serial.println("[CAP] esp_camera_fb_get failed");
    return false;
  }

  // Tao folder
  String dateFolder = getDateFolder();
  String dirPath = String(BASE_DIR) + "/" + dateFolder;
  if (!ensureDir(SD_MMC, BASE_DIR)) {
    Serial.println("[SD] create base dir failed");
    esp_camera_fb_return(fb);
    return false;
  }
  if (!ensureDir(SD_MMC, dirPath)) {
    Serial.println("[SD] create date dir failed");
    esp_camera_fb_return(fb);
    return false;
  }

  // Chien luoc chong full (B): gioi han so file trong folder
  uint32_t count = countJpgInDir(SD_MMC, dirPath);
  if (count >= MAX_FILES) {
    uint32_t del = deleteOldestBatch(SD_MMC, dirPath, DELETE_BATCH);
    Serial.printf("[SD] folder full -> deleted %u old files\n", del);
  }

  String fileName = getFileNameJpg();
  String filePath = dirPath + "/" + fileName;

  File file = SD_MMC.open(filePath, FILE_WRITE);
  if (!file) {
    Serial.println("[SD] open file failed");
    esp_camera_fb_return(fb);
    return false;
  }

  size_t written = file.write(fb->buf, fb->len);
  file.close();

  esp_camera_fb_return(fb);

  if (written != fb->len) {
    Serial.printf("[SD] write mismatch: %u/%u\n", (unsigned)written, (unsigned)fb->len);
    return false;
  }

  Serial.printf("[CAP] Saved: %s (%u bytes)\n", filePath.c_str(), (unsigned)fb->len);
  return true;
}

// ================== APP ==================
uint32_t lastShotMs = 0;
const uint32_t SHOT_INTERVAL_MS = 8000; // demo: 8s chup 1 anh

void setup() {
  Serial.begin(115200);
  delay(600);

  Serial.println("\n=== ESP32-S3 CAM: Capture to microSD ===");
  Serial.printf("[MEM] Heap: %u | PSRAM: %u | PSRAM found: %s\n",
                ESP.getFreeHeap(), ESP.getFreePsram(), psramFound() ? "YES" : "NO");

  if (!initSDMMC()) {
    Serial.println("[FATAL] SD init failed");
    return;
  }

  if (!initCamera()) {
    Serial.println("[FATAL] Camera init failed");
    return;
  }

  trySyncTimeNTP();

  Serial.println("[READY] Will capture periodically...");
}

void loop() {
  // Demo chup dinh ky
  if (millis() - lastShotMs >= SHOT_INTERVAL_MS) {
    lastShotMs = millis();

    Serial.printf("[MEM] Heap: %u | PSRAM: %u\n", ESP.getFreeHeap(), ESP.getFreePsram());

    bool ok = savePhotoToSD();
    Serial.println(ok ? "[OK] capture saved" : "[ERR] capture failed");
  }

  delay(50);
}

Giải thích nhanh (để bạn tự tuỳ chỉnh)

1) mode1bit khi mount SD_MMC

  • SD_MMC.begin("/sdcard", true) sẽ mount 1-bit (ổn định hơn nếu dây tín hiệu kém)
  • Nếu thất bại, code thử tiếp 4-bit

2) Vì sao sort theo tên file lại xoá đúng file cũ

Nếu tên file theo format IMG_YYYYMMDD_HHMMSS.jpg thì sort chuỗi sẽ đúng thứ tự thời gian.

3) Khi nào cần giảm frame_size

  • Nếu thấy reset / capture fail / file size quá to
  • Thử FRAMESIZE_VGA hoặc FRAMESIZE_QVGA để ổn định

4. Lỗi thường gặp và cách xử lý

Lỗi 1: SD_MMC mount failed

  • Thẻ chưa format FAT32
  • Thẻ kém chất lượng / tiếp xúc kém
  • Thử 1-bit mode (code đã có)

Lỗi 2: File ghi ra 0 bytes / write mismatch

  • Nguồn sụt áp
  • Thẻ quá chậm
  • Giảm frame size, giảm jpeg quality, đổi thẻ tốt hơn

Lỗi 3: Camera crash sau vài chục tấm

  • Heap/PSRAM bị cạn
  • Giảm fb_count về 1 nếu không cần tốc độ
  • Đảm bảo luôn gọi esp_camera_fb_return(fb)

5. Best practices (chọn lọc)

  • Luôn có cơ chế chống full thẻ (A/B/C)
  • Test đọc/ghi thẻ riêng (Bài 08) trước khi kết hợp camera
  • Nếu cần timestamp chuẩn, hãy dùng NTP (WiFi) hoặc thêm RTC
  • Khi chạy 24/7, giảm độ phân giải và tần suất chụp