📋 專案概述

妹妹住汐止的獨棟公寓,一個人住。三個月內她的門口被順走兩次包裹——第二次是顆 1,800 元的咖啡膠囊機,監視器畫面裡一個穿外送背心的男人在她門口蹲了 40 秒,抱起紙箱就走。報警說「沒有門鎖破壞、難以追訴」,損失 2,300 元買了一個教訓:包裹放在門口的每一分鐘,都是公開財產。

市售方案查過一輪:某牌視訊電鈴 2,290 元,事件影片要訂閱雲端儲存每月 170 元,不訂閱只給你 3 秒預覽縮圖;更別說所有訪客影像都上傳到廠商伺服器——她家門口長什麼樣子,憑什麼讓一家新加坡公司存著。而且大樓管委會禁止外牆鑽孔,裝不了有線門鈴。

成本盤一盤:XIAO ESP32-S3 Sense(附 OV2640 相機)290 元、AM312 PIR 25 元、TP4056 充電板 10 元、402030 鋰聚合物 45 元、其餘雜項 15 元,總計約 385 元——不到市售電鈴的零頭,影像只進她自己的 Telegram,零訂閱費。

本專案功能清單:

為什麼選 ESP32-S3?

需求S3 的優勢
相機介面全家族只有 S3(與經典 ESP32)有 DVP 相機介面;S3 版 PSRAM 配置讓 JPEG 緩衝不再捉襟見肘
影像差分512KB SRAM + 向量指令(PIE),96×96 灰階差分一次不到 2ms,不需要外接 PSRAM 就跑得動
待機功耗深睡 ~5µA(關閉 RTC 外設後更低);PIR 喚醒 → 開機 → 拍照 → 推播 → 睡,平均電流壓到 µA 級
體積XIAO 21×17.8mm,相機板同尺寸堆疊,整台塞得進門鈴造型殼

🧱 硬體清單

項目型號數量預估單價備註
主控 + 相機Seeed XIAO ESP32-S3 Sense(含 OV2640)1~290 元買含相機版;Sense 天線在板背,殼勿遮
人體偵測AM312 迷你 PIR1~25 元待機 50µA,比 HC-SR501 的 50mA 省一千倍
充電板TP4056 + DW01 保護版1~10 元必選有保護版
電池402030 鋰聚合物 230mAh1~45 元相機突波 250mA,太小的電池會壓垮電壓
MOSFETAO3400 N-MOS1~3 元夜間補光 LED 的低邊開關
補光 LED白光 2835 + 10Ω1~3 元夜拍補光 0.5 秒,亮度夠 QQVGA 就贏
狀態 LED0805 綠 LED + 1kΩ1~2 元推播成功才閃,避免閃燈暴露裝置
其他3M VHB 雙面膠、熱熔膠、編織線1 組~10 元
合計~385 元市售視訊電鈴 2,290 元 + 月費 170 元起

🔌 接線圖

XIAO ESP32-S3 Sense · 相機朝外 OV2640 相機 BAT+ 焊盤 D1 (GPIO2) 3V3 GND D0 (GPIO1) D2 (GPIO3) AM312 PIR VCC OUT GND 門口 1.2m 高、朝外 AO3400 補光閘 G←D0 · S→GND D→白光 LED(夜拍補光) LED+→BAT+ 狀態 LED+1kΩ 402030 鋰包 230mAh → BAT 焊盤 OUT 3V3→VCC D0→G GND→S D2→狀態 LED BAT+
圖 1:接線示意圖 — PIR 常駐供電負責喚醒;AO3400 只在快門瞬間開啟補光
XIAO ESP32-S3 Sense        週邊
┌─────────────────┐
│  OV2640(板載)  │← 相機排線已預焊,免接
│                 │
│  D1  GPIO2 ─────┼──→ AM312 OUT(PIR 高準位觸發、喚醒源)
│  D0  GPIO1 ─────┼──→ AO3400 Gate(HIGH=補光點亮)
│  D2  GPIO3 ─────┼──→ 狀態 LED+1kΩ → GND
│  3V3 ───────────┼──→ AM312 VCC
│  GND ───────────┼──→ AM312 GND、AO3400 Source
│  BAT+ 焊盤 ─────┼──→ 402030 紅線;補光 LED+ 並接同一點
└─────────────────┘
AO3400:Drain → 補光 LED−(經 10Ω)
TP4056:IN+ / IN- ← Type-C;B+ / B- ← 電池
⚠️ Sense 版相機佔用 GPIO10~18、38~40、47、48——外設只能用 D0~D10 剩餘腳。本專案刻意只花 3 支 GPIO,留 D4/D5 給你加裝門鈴按鍵或第二顆 PIR。

⚙️ 運作原理

PIR 喚醒 深睡 <0.1mA 拍照 ×2 QQVGA + 補光 分塊差分 6×6 區塊比基準 Telegram 照片 + 事件推播 深睡 更新基準 變化 <30%(光影/窗簾)→ 不推播直接睡
圖 2:事件流程 — 差分變化不足 30% 視為誤觸,連 Wi-Fi 都不開

關鍵設計有兩個。第一,「有沒有人」交給 PIR,「發生了什麼事」交給影像差分:PIR 只負責省電喚醒,真正決定要不要打擾你的是 6×6 分塊灰階差分——窗簾飄動只動一兩塊,包裹放下/被抱走會蓋掉三成的塊。第二,基準圖的更新時機:包裹放下後那張圖必須成為新基準,否則之後每張照片都會「偵測到變化」;但更新也不能太即時,否則小偷慢慢搬會被當成背景。實作採「事件當下若場景改變就重存基準」,搭配 3 秒冷卻期,實測穩定。

💻 Arduino 程式碼

板型選 XIAO_ESP32_S3、PSRAM 選 OPI PSRAM(Arduino ESP32 core ≥ 3.0)。全部使用內建函式庫,無需安裝第三方套件:

/* =====================================================
 * package-watch.ino — ESP32-S3 智慧包裹哨兵 v1.0
 * 功能:PIR 喚醒 → 補光連拍 → 分塊影像差分 → Telegram 推播 → 深睡
 * 硬體:XIAO ESP32-S3 Sense(OV2640)+ AM312 PIR + AO3400 白光補光 + 狀態 LED
 * 供電:402030 鋰包 → BAT 焊盤;待機實測 < 0.1mA
 * 相依:Arduino ESP32 core ≥ 3.0(esp_camera/WiFi/HTTPClient 內建)
 * ===================================================== */
#include <esp_camera.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <Preferences.h>
#include <esp_sleep.h>

/* --- 1. 腳位與憑證 --- */
#define PIN_PIR    2   // D1:AM312 OUT,EXT0 喚醒源(HIGH=有人)
#define PIN_FLASH  1   // D0:AO3400 閘極,HIGH=補光點亮(夜間拍照用)
#define PIN_LED    3   // D2:推播成功才閃一下,避免常亮暴露裝置

const char* WIFI_SSID = "家裡的WiFi";      // 建議給它獨立 SSID
const char* WIFI_PASS = "wifi密碼";
const char* TG_TOKEN  = "123456:ABC-DEF..."; // @BotFather 申請
const char* TG_CHAT   = "123456789";         // @userinfobot 查自己的 chat_id

/* --- 2. 相機腳位(XIAO S3 Sense 出廠固定,改不得)--- */
#define CAM_XCLK 10
#define CAM_SIOD 40
#define CAM_SIOC 39
#define CAM_VSYNC 38
#define CAM_HREF 47
#define CAM_PCLK 13
#define CAM_D0 15
#define CAM_D1 17
#define CAM_D2 18
#define CAM_D3 16
#define CAM_D4 14
#define CAM_D5 12
#define CAM_D6 11
#define CAM_D7 48

/* --- 3. 差分參數(QQVGA 160x120 灰階化後分 6x6 塊)--- */
#define DIFF_W 160
#define DIFF_H 120
#define NBLK   6                    // 6x6 = 36 塊
#define BLK_W  (DIFF_W / NBLK)      // 每塊 26px 寬
#define BLK_H  (DIFF_H / NBLK)      // 每塊 20px 高
#define BLK_DELTA 26                // 單塊平均灰階差 >26 記為「變化塊」
#define SCENE_PCT 30                // 變化塊 >30% 才算場景改變
#define COOLDOWN_MS 3000            // 冷卻:防搬運動作被拆成多事件

static uint8_t baseline[DIFF_H][DIFF_W];
static bool hasBaseline = false;
Preferences prefs;

/* camInit: 初始化相機
 * 參數:無(QQVGA、JPEG 品質 12、無翻轉)
 * 回傳:true 成功;相機首開失敗率高,外部重試 3 次 */
bool camInit() {
  camera_config_t c = {};
  c.pin_xclk = CAM_XCLK; c.pin_sccb_sda = CAM_SIOD; c.pin_sccb_scl = CAM_SIOC;
  c.pin_vsync = CAM_VSYNC; c.pin_href = CAM_HREF; c.pin_pclk = CAM_PCLK;
  c.pin_d0 = CAM_D0; c.pin_d1 = CAM_D1; c.pin_d2 = CAM_D2; c.pin_d3 = CAM_D3;
  c.pin_d4 = CAM_D4; c.pin_d5 = CAM_D5; c.pin_d6 = CAM_D6; c.pin_d7 = CAM_D7;
  c.xclk_freq_hz = 20000000;
  c.pixel_format = PIXFORMAT_RGB565;  // RGB565:同一張圖兼做差分與 JPEG
  c.frame_size = FRAMESIZE_QQVGA;
  c.jpeg_quality = 12;
  c.fb_count = 2;                     // 雙緩衝:丟棄首幀穩定曝光
  return esp_camera_init(&c) == ESP_OK;
}

/* grabGray: 抓一張並轉灰階
 * 參數:gray[] 輸出 120x160 灰階緩衝
 * 回傳:true 成功
 * 註:RGB565 轉灰階用定點權重,省浮點運算 */
bool grabGray(uint8_t gray[DIFF_H][DIFF_W]) {
  camera_fb_t* fb = nullptr;
  for (int i = 0; i < 3 && !fb; i++) fb = esp_camera_fb_get();  // 首幀常 NULL
  if (!fb) return false;
  uint16_t* px = (uint16_t*)fb->buf;
  for (int y = 0; y < DIFF_H; y++)
    for (int x = 0; x < DIFF_W; x++) {
      uint16_t p = px[y * fb->width + x];
      uint8_t r = (p >> 11) << 3, g = ((p >> 5) & 0x3F) << 2, b = (p & 0x1F) << 3;
      gray[y][x] = (uint8_t)((r * 77 + g * 151 + b * 28) >> 8);  // ITU-R 601 近似
    }
  esp_camera_fb_return(fb);
  return true;
}

/* sceneDiff: 與基準圖分塊比對
 * 參數:gray 目前灰階圖;outDeltaAvg 回傳變化塊平均亮度差
 * 回傳:變化塊百分比 0~100;<0 代表無基準(首次運行) */
int sceneDiff(uint8_t gray[DIFF_H][DIFF_W], int& outDeltaAvg) {
  if (!hasBaseline) return -1;
  int changed = 0, deltaSum = 0;
  for (int by = 0; by < NBLK; by++)
    for (int bx = 0; bx < NBLK; bx++) {
      long sum = 0;
      for (int y = by * BLK_H; y < (by + 1) * BLK_H; y++)
        for (int x = bx * BLK_W; x < (bx + 1) * BLK_W; x++)
          sum += abs((int)gray[y][x] - baseline[y][x]);
      int avg = sum / (BLK_W * BLK_H);
      if (avg > BLK_DELTA) { changed++; deltaSum += avg; }
    }
  outDeltaAvg = changed ? deltaSum / changed : 0;
  return changed * 100 / (NBLK * NBLK);
}

/* tgSendPhoto: Telegram 推播照片
 * 參數:jpg JPEG 位元組流;caption 文字說明
 * 回傳:true 送達(回應首行含 200)
 * 註:走原生 HTTPS POST 而非 HTTPClient——後者不支援串流式
 *     multipart 大內文;setInsecure 換啟動速度,照片不是機密,
 *     被中間人竊聽的風險遠低於錯過竊賊的風險 */
bool tgSendPhoto(const uint8_t* jpg, size_t len, const String& caption) {
  WiFiClientSecure client;
  client.setInsecure();
  if (!client.connect("api.telegram.org", 443)) return false;
  String head = "--esp\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\n"
              + String(TG_CHAT) +
              "\r\n--esp\r\nContent-Disposition: form-data; name=\"caption\"\r\n\r\n"
              + caption +
              "\r\n--esp\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"f.jpg\"\r\n"
              "Content-Type: image/jpeg\r\n\r\n";
  String tail = "\r\n--esp--\r\n";
  client.print(String("POST /bot") + TG_TOKEN + "/sendPhoto HTTP/1.1\r\n"
               "Host: api.telegram.org\r\n"
               "Content-Type: multipart/form-data; boundary=esp\r\n"
               "Content-Length: " + String(head.length() + len + tail.length()) + "\r\n"
               "Connection: close\r\n\r\n");
  client.print(head);
  client.write(jpg, len);          // 圖片本體直接串流,不進字串緩衝
  client.print(tail);
  String resp = client.readStringUntil('\n');  // 首行 HTTP/1.1 200 OK
  client.stop();
  return resp.indexOf("200") > 0;
}

/* deepSleepNow: 清場並進入深睡
 * 參數:無;喚醒源 = PIR(EXT0 HIGH)+ 2 小時心跳定時器 */
void deepSleepNow() {
  esp_sleep_enable_ext0_wakeup(GPIO_NUM_2, 1);   // GPIO2(D1)高準位=有人
  esp_sleep_enable_timer_wakeup(2ULL * 3600 * 1000000);  // 心跳:確認裝置還活著
  esp_deep_sleep_start();
}

void setup() {
  pinMode(PIN_LED, OUTPUT);
  pinMode(PIN_FLASH, OUTPUT);
  digitalWrite(PIN_FLASH, LOW);
  prefs.begin("watch");
  hasBaseline = (prefs.getBytes("base", baseline, sizeof(baseline)) == sizeof(baseline));

  // 心跳喚醒(非 PIR):不拍照不推播,只報平安後繼續睡
  if (esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_EXT0 &&
      esp_sleep_get_wakeup_cause() != ESP_SLEEP_WAKEUP_UNDEFINED) {
    if (digitalRead(PIN_PIR) == LOW) deepSleepNow();
  }

  if (!camInit()) { delay(500); esp_restart(); }   // 相機掛了重來最快
  delay(300);                                       // 等自動曝光穩定

  static uint8_t gray[DIFF_H][DIFF_W];
  if (!grabGray(gray)) esp_restart();

  int deltaAvg = 0;
  int pct = sceneDiff(gray, deltaAvg);

  // 誤觸過濾:場景幾乎沒變 → 關相機直接睡,Wi-Fi 都不開
  if (pct >= 0 && pct < SCENE_PCT) { delay(COOLDOWN_MS / 2); deepSleepNow(); }

  // 拍正式照(第二張:曝光已穩,避開補光後的自動增益殘影)
  digitalWrite(PIN_FLASH, HIGH);
  camera_fb_t* shot = nullptr;
  for (int i = 0; i < 3 && !shot; i++) shot = esp_camera_fb_get();
  digitalWrite(PIN_FLASH, LOW);
  if (!shot) esp_restart();

  uint8_t* jpg = nullptr; size_t jpgLen = 0;
  bool enc = frame2jpg(shot, 12, &jpg, &jpgLen);  // 統一編 JPEG
  esp_camera_fb_return(shot);
  if (!enc) esp_restart();

  // 事件語意:變化塊變暗 ≈ 深色包裹放入;變亮 ≈ 包裹移走露出背景
  String caption = (pct < 0) ? "🛡️ 哨兵上線(首次基準建立)"
                   : (deltaAvg > 0 ? "📦 門口出現新物體(包裹送達?)"
                                   : "⚠️ 門口物體消失(包裹被拿走!)");
  caption += "\n場景變化 " + String(pct < 0 ? 100 : pct) + "% · " +
             String(millis() / 1000) + "s";

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(500);
  bool sent = false;
  if (WiFi.status() == WL_CONNECTED) {
    configTime(8 * 3600, 0, "pool.ntp.org");  // 等時間同步,照片註解才有時序
    delay(800);
    sent = tgSendPhoto(jpg, jpgLen, caption);
    if (!sent) sent = tgSendPhoto(jpg, jpgLen, caption);  // 網路抖動重試一次
  }
  free(jpg);

  if (sent) {                       // 推播成功才閃燈、才更新基準
    digitalWrite(PIN_LED, HIGH); delay(120); digitalWrite(PIN_LED, LOW);
    if (pct >= SCENE_PCT || pct < 0) {
      memcpy(baseline, gray, sizeof(baseline));  // 新場景成為基準
      prefs.putBytes("base", baseline, sizeof(baseline));
    }
  }
  WiFi.disconnect(true);
  delay(100);                       // 讓 Wi-Fi 電路放電完全
  deepSleepNow();
}

void loop() { }  // 單次任務架構:所有事在 setup() 做完就睡
💡 為什麼用「單次任務架構」而不是主迴圈輪詢?深睡喚醒後 MCU 從 setup() 重新開始跑,跟冷開機一樣——這樣任何當機都只會損失一次事件,不會讓裝置卡在迴圈裡把電耗光。這是電池產品的鐵律:把韌體寫成「一次性的」,壞了睡一覺就好。

🖨️ 3D 列印設計

零件材質數量列印設定備註
門鈴主殼PETG10.2mm / 3 壁 / 20% 填充視窗開孔 24×24mm;相機 28° 俯角斜面
壁座PETG10.2mm / 4 壁 / 30% 填充背板 60×60mm 貼 VHB;卡扣式免螺絲
電池艙蓋PETG10.2mm / 100% 填充卡榫設計,拆裝免工具
PIR 導光罩透明 PETG12 壁 / 實心限縮感應角到 60°,防走廊深處誤觸
雨簷蓋(選配)PETG10.2mm / 20%半戶外安裝用,延伸 25mm 遮雨

視窗不另外買壓克力片——PETG 兩層壁本身就是合格的視窗(輕微霧化不影響 QQVGA 解析度),少一個零件少一次組裝失敗。主殼與壁座用同一種 PETG,卡扣尺寸才對得準。

🔧 組裝步驟

📘 圖文版組裝說明書:IKEA 風格 6 步驟卡片、SVG 示意圖、VHB 貼附與基準校準流程,可直接列印 A4 → 開啟 HTML 組裝說明書
  1. 列印零件:主殼/壁座 PETG;先列印壁座試卡扣,太緊用 600 號砂紙修
  2. 焊電池與補光:402030 接 TP4056 再上 BAT 焊盤;AO3400 閘極走線先量 Gate 對 GND 電阻應 >1MΩ
  3. 燒錄與差分校準:先不鎖殼,序列埠開機跑一次基準建立;對鏡頭前 80cm 放一個紙箱,確認推播「新物體」;拿走確認推播「物體消失」
  4. PIR 感應測試:以正常步速走過鏡頭前 1.2m 處,應喚醒;站定不動 30 秒不應再觸發(AM312 只偵測移動)
  5. 鎖殼貼牆:VHB 貼上後壓 30 秒、靜置 24 小時再掛機(背膠強度 72 小時才到頂)
  6. 72 小時驗收:正常生活一週,誤報應為 0;若窗簾方向誤報,把 PIR 導光罩再限縮或調高 SCENE_PCT 至 40

🛠️ 問題排除

問題原因解決方法
相機初始化失敗/花圖PSRAM 未選 OPI;XCLK 過高板型確認 XIAO_ESP32S3 + OPI PSRAM;仍花圖把 xclk 降 16MHz
拍照成功但推播沒收到chat_id 錯 / Token 洩被限流@userinfobot 重查 chat_id;Bot 被太多人用就重開一個
半夜照片全黑補光 LED 焊反 / 閘極腳位錯手動 digitalWrite(PIN_FLASH, HIGH) 測試;AO3400 是低邊開關,LED 負極接 Drain
誤報頻繁PIR 對著會動的東西(窗簾/寵物)導光罩限縮感應角;SCENE_PCT 調高到 40;安裝高度降到 1.1m 避開貓跳台高度
續航掉到幾天Wi-Fi 連線失敗一直重試 / 事件量比預期高序列埠看平均每日事件數;超過 30 次代表 PIR 太靈敏,先修誤報再談續航
喚醒後死機重啟迴圈相機突波壓垮老舊鋰包換 230mAh 以上新電池;電池線加 100µF 電解緩衝

🚀 進階延伸

📦 檔案清單

package-watch/
├── src/
│   └── package-watch.ino        # 完整程式碼(本頁)
├── stl/
│   ├── doorbell-shell.stl       # 門鈴主殼(PETG)
│   ├── wall-mount.stl           # 無鑽孔壁座
│   ├── battery-cover.stl        # 電池艙蓋
│   ├── pir-shroud.stl           # PIR 導光罩
│   └── rain-hood.stl            # 雨簷蓋(選配)
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md
← 上一篇:ESP32-C6 智慧水表 📚 系列索引 下一篇:ESP32-H2 氣密窗保全感測器 →