📋 專案概述
妹妹住汐止的獨棟公寓,一個人住。三個月內她的門口被順走兩次包裹——第二次是顆 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,零訂閱費。
本專案功能清單:
- PIR 喚醒連拍:待機 <5µA;有人靠近 → 醒來補光連拍 2 張 → Telegram 照片 + 時間推播,3 秒內送達
- 包裹在/離場偵測:96×96 灰階分塊影像差分——「放下一件包裹」通知「📦 包裹送達」,「包裹被抱走」通知「⚠️ 包裹已離開原位」
- 誤觸過濾:PIR 觸發但影像幾乎無變化(窗簾飄動、光影移動)→ 不推播,實測把誤報率壓到近零
- 本地隱私:影像只走 Telegram API 直連她的手機,不經過任何第三方平台,可隨時一鍵刪除訊息
- 免鑽孔、免佈線:3M VHB 背膠壁座 + 28° 俯角列印殼,整台 250g,租屋族友善
- 續航 2~3 週:402030 鋰包 230mAh,每日 10 次事件估算;Type-C 充電,充飽約 2 小時
為什麼選 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 迷你 PIR | 1 | ~25 元 | 待機 50µA,比 HC-SR501 的 50mA 省一千倍 |
| 充電板 | TP4056 + DW01 保護版 | 1 | ~10 元 | 必選有保護版 |
| 電池 | 402030 鋰聚合物 230mAh | 1 | ~45 元 | 相機突波 250mA,太小的電池會壓垮電壓 |
| MOSFET | AO3400 N-MOS | 1 | ~3 元 | 夜間補光 LED 的低邊開關 |
| 補光 LED | 白光 2835 + 10Ω | 1 | ~3 元 | 夜拍補光 0.5 秒,亮度夠 QQVGA 就贏 |
| 狀態 LED | 0805 綠 LED + 1kΩ | 1 | ~2 元 | 推播成功才閃,避免閃燈暴露裝置 |
| 其他 | 3M VHB 雙面膠、熱熔膠、編織線 | 1 組 | ~10 元 | |
| 合計 | ~385 元 | 市售視訊電鈴 2,290 元 + 月費 170 元起 |
🔌 接線圖
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- ← 電池
⚙️ 運作原理
關鍵設計有兩個。第一,「有沒有人」交給 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() 做完就睡
setup() 重新開始跑,跟冷開機一樣——這樣任何當機都只會損失一次事件,不會讓裝置卡在迴圈裡把電耗光。這是電池產品的鐵律:把韌體寫成「一次性的」,壞了睡一覺就好。🖨️ 3D 列印設計
| 零件 | 材質 | 數量 | 列印設定 | 備註 |
|---|---|---|---|---|
| 門鈴主殼 | PETG | 1 | 0.2mm / 3 壁 / 20% 填充 | 視窗開孔 24×24mm;相機 28° 俯角斜面 |
| 壁座 | PETG | 1 | 0.2mm / 4 壁 / 30% 填充 | 背板 60×60mm 貼 VHB;卡扣式免螺絲 |
| 電池艙蓋 | PETG | 1 | 0.2mm / 100% 填充 | 卡榫設計,拆裝免工具 |
| PIR 導光罩 | 透明 PETG | 1 | 2 壁 / 實心 | 限縮感應角到 60°,防走廊深處誤觸 |
| 雨簷蓋(選配) | PETG | 1 | 0.2mm / 20% | 半戶外安裝用,延伸 25mm 遮雨 |
視窗不另外買壓克力片——PETG 兩層壁本身就是合格的視窗(輕微霧化不影響 QQVGA 解析度),少一個零件少一次組裝失敗。主殼與壁座用同一種 PETG,卡扣尺寸才對得準。
🔧 組裝步驟
- 列印零件:主殼/壁座 PETG;先列印壁座試卡扣,太緊用 600 號砂紙修
- 焊電池與補光:402030 接 TP4056 再上 BAT 焊盤;AO3400 閘極走線先量 Gate 對 GND 電阻應 >1MΩ
- 燒錄與差分校準:先不鎖殼,序列埠開機跑一次基準建立;對鏡頭前 80cm 放一個紙箱,確認推播「新物體」;拿走確認推播「物體消失」
- PIR 感應測試:以正常步速走過鏡頭前 1.2m 處,應喚醒;站定不動 30 秒不應再觸發(AM312 只偵測移動)
- 鎖殼貼牆:VHB 貼上後壓 30 秒、靜置 24 小時再掛機(背膠強度 72 小時才到頂)
- 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 電解緩衝 |
🚀 進階延伸
- 雙機交叉驗證:對面牆再掛一台 290 元的哨兵,兩台都看到同一事件才推播——誤報歸零
- 人臉模糊化:推播前對差分變化塊做 8×8 馬賽克,法律與鄰里關係兩清
- Home Assistant 整合:推播改發 MQTT(本機 broker),與電燈/喇叭聯動「您有包裹送達」
- TinyML 升級:換 ESP32-S3 + TensorFlow Lite,直接辨識「纸箱 vs 人」,語意比亮度差分準一個世代
📦 檔案清單
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