📋 專案概述
上個月第一批繡球菌,出菇那天早上興奮起床要拍照——結果「三天前從哪天開始冒蕾、多久長出一指」完全沒紀錄。3D 列印也一樣:20 小時的長件堵頭都是隔天收件才發現,失敗的那 900g 材料直接當學費。生長與失敗都是時間軸上的事,而人不能 24 小時瞪著。
時間紀錄器這個位置上的現有選項,每一個都有代價:
- GoPro 縮時:4K 檔案地獄(一張 8MB、一天兩萬張起跳),每 12 小時搶著充電,記憶體卡兩週就滿。
- 手機架著拍:兩小時後螢幕降亮度、App 被系統回收殺掉,第二天只剩 11 張照片和過熱的電池。
- 市售室內縮時機:NT$2,900 起跳,附一個要註冊帳號的雲 App,檔案壓到沒朋友,訂閱到期畫面打結。
這台 NT$720 的桌上型縮時攝影機把問題縮到最小:每 N 秒拍一張、直接丟家中 NAS,功能清單:
- 定時拍攝:10 秒~1 小時間距旋鈕可調,跨天連續任務不斷號
- NAS 直傳:VGA JPEG 約 30KB,24 小時 @30 秒間距才 90MB——GoPro 的 1/250 體積,NAS 裡放一年不心疼
- 離線緩存:NAS 掛掉/Wi-Fi 斷線自動進閃存重傳佇列(約 25 張,丟最舊保新),訊號恢復自動補傳補齊
- AE 防閃爍:50Hz 燈頻鎖定+預熱後鎖曝光,成片不呼吸(呼吸現象是縮時最大殺手,見下方解圖)
- 斷電續號:幀號由 NTP 時間推導,重開機接回原序列,不重號不缺號
- OLED 狀態:幀號/倒數/上傳成敗/佇列深度,一眼知道它活不活
NAS 端 ffmpeg 一行命令出 24fps 影片;「最新一幀」直接就是菇棚遠端監視畫面,等於免費送一台監視器。
🔬 為什麼選 ESP32-S3(選型說明)
| 需求 | S3 的對應 |
|---|---|
| 摄像头介面 | DVP 8-bit 並行匯流排只有 ESP32 Classic 與 S3 有——S3 是其現代代表,C 系列全軍沒這條匯流排 |
| JPEG 緩衝 | PSRAM 當畫布,VGA 品質 26 的 JPEG 池不擠占 SRAM heap |
| 拍攝不卡網 | 雙核:核心 0 收幀、核心 1 跑上傳與重試,互不拖腿 |
| 省電可選 | 長間距(≥5 分鐘)可改 deep sleep 版本,電池跑整週(進階延伸) |
| 不需要的 | 喇叭、燈環——第 28 篇顯微鏡那張腳位表本篇原封不動沿用 |
📐 開發環境:arduino-esp32 core ≥ 3.0,開發板
ESP32S3 Dev Module、PSRAM: OPI PSRAM(N16R8 板必選,選錯=拍不到圖第一大原因)、相機用 core 內建 esp_camera。🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-S3-DevKitC-1 N16R8 | 1 | ~480 元 | 與第 28 篇同板同腳位,顯微鏡老玩家直接沿用 |
| 相機 | OV2640 DVP 模组(FPC 轉接板) | 1 | ~120 元 | 排線 ≤15cm,金手指方向見組裝步驟 1 |
| 螢幕 | SSD1306 0.96" I2C(0x3C) | 1 | ~55 元 | SDA40 / SCL39,避開相機腳域 |
| 編碼器 | KY-040 旋編(附按鈕) | 1 | ~25 元 | 調間距/暫停/試拍 |
| 供電 | USB-C 5V/2A 線+電源 | 1 | ~40 元 | 相機+Wi-Fi 峰值 ~300mA,線材要撐得住 |
| 列印料 | PLA/PETG ~60g | — | ~20 元 | 三腳架轉接件必列印 |
| 合計 | ~720 元 | 市售雲相機的 1/4,檔案 100% 在自己 NAS |
🔌 接線圖
ASCII 對照表(與第 28 篇顯微鏡同表)
ESP32-S3 DevKitC-1 週邊 ────────────────── ───────────────────────────── GPIO15 ──────── OV2640 XCLK 20MHz 主時脈 GPIO1 ──────── OV2640 SIOD SCCB 資料 GPIO2 ──────── OV2640 SIOC SCCB 時脈 GPIO3 ──────── OV2640 VSYNC GPIO4 ──────── OV2640 HREF GPIO5 ──────── OV2640 PCLK GPIO6~13 ────── OV2640 D0~D7(Y2~Y9) GPIO40 ──────── OLED SDA 避開相機腳域 GPIO39 ──────── OLED SCL GPIO16/17/18 ── 編碼器 A/B/SW 內部上拉 3V3 ──────── 相機 VIN、OLED、KY-040 GND ──────── 全系統共地 ⛔ GPIO19/20(USB)、26–32(Flash)、35–37 —— 禁接
⚠️ 禁接腳沒有商量的餘地:GPIO26–32 連著板載 Flash,碰到=不開機(不是壞,是開不了機直到你把線拔掉);GPIO19/20 是燒錄用的 USB。寫腳位表時先把禁接區畫掉再分配。
🎯 它的日常:拍攝管線長什麼樣
🎯 縮時殺手「呼吸現象」與本機的處方
🖨️ 3D 列印設計
零件清單
| 零件 | 數量 | 材質建議 | 列印設定 | 備註 |
|---|---|---|---|---|
| 相機艙 | 1 | PETG | 0.2mm/3 壁/20% | 鏡頭孔 φ16mm;艙內全黑化(消光黑噴漆或貼黑紙) |
| 遮光罩 | 1 | PLA | 0.2mm/2 壁/10% | 長 3 倍口徑;逆光場景救星,可拆 |
| 主板盒 | 1 | PLA | 0.2mm/3 壁/15% | USB-C 側出+排線出口;不封死(相機要散熱) |
| 三腳架轉接件 | 1 | PETG | 0.2mm/4 壁/60% | 1/4" 螺孔嵌銅螺母;相機艙側鎖 |
| OLED 斜支架 | 1 | PLA | 0.2mm/3 壁/15% | 30° 仰角,站立視線可讀 |
| 旋鈕帽+線夾 | 各 1 | PLA | 0.16mm/4 壁 | D 型軸;線夾收 14 芯排線拐弯半徑 ≥10mm |
設計要點
- 鏡頭凹台朝下防塵:相機艙立放時鏡頭陷進凹台 2mm——落在桌面上的灰塵永遠碰不到鏡片,菇棚的孢子也一樣
- 排線是脆的:FPC 排線折弯半徑 ≥10mm、出口做導引槽鎖線夾——縮時相機一放一個月,應力集中處遲早斷線
- 艙內消光:PETG 半透明會漏光進鏡頭形成霧翳;內壁消光黑處理,鏡頭孔內緣加 2mm 遮光簷
- 散熱不封死:S3+Wi-Fi+相機連續動作 ~1.5W,主板盒留上下對流孔;夏天悶在菇棚 35°C,封閉殼會熱到降頻跳幀
- 1/4 螺孔嵌銅螺母:列印件直接攻牙撐不過 20 次鎖拆,熱嵌 M4 銅螺母才經得起三腳架反覆装卸
🧵 列印備註:相機艙 PETG 的好處是韌——掉桌下鏡頭模组還活著;要上防塵改版(蓋式)時 PETG 也耐酒精擦拭。
💻 程式碼(韌體)
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-S3 桌上型縮時攝影機 | s3-timelapse-cam.ino
* ------------------------------------------------------------
* 功能:定時拍攝(10s~1h 旋鈕可調)— NAS HTTP 直傳(token 認證)、
* 離線 LittleFS 重傳佇列(25 張上限丟舊保新)、
* AE 預熱鎖定+50Hz 防閃、NTP 推導幀號(斷電續號)、
* OLED 狀態(幀號/倒數/成敗/佇列)、看門狗護身
* 硬體:ESP32-S3 DevKitC-1 N16R8 + OV2640(DVP) + SSD1306 + KY-040
* 供電:USB-C 5V/2A(相機+Wi-Fi 峰值 ~300mA,線材要撐得住)
* 相依:arduino-esp32 core >= 3.0、板設定 PSRAM: OPI PSRAM、
* Adafruit GFX / SSD1306 >= 2.5
* ============================================================ */
#include "esp_camera.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include <LittleFS.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <esp_task_wdt.h>
#include <time.h>
/* --- 1. 腳位(與第 28 篇顯微鏡同表)-------------------------- */
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 15
#define SIOD_GPIO_NUM 1
#define SIOC_GPIO_NUM 2
#define Y9_GPIO_NUM 6 // D0..D7 = GPIO6..13
#define Y8_GPIO_NUM 7
#define Y7_GPIO_NUM 8
#define Y6_GPIO_NUM 9
#define Y5_GPIO_NUM 10
#define Y4_GPIO_NUM 11
#define Y3_GPIO_NUM 12
#define Y2_GPIO_NUM 13
#define VSYNC_GPIO_NUM 3
#define HREF_GPIO_NUM 4
#define PCLK_GPIO_NUM 5
#define I2C_SDA 40 // OLED:避開相機腳域
#define I2C_SCL 39
#define ENC_A 16
#define ENC_B 17
#define ENC_SW 18
/* --- 2. Wi-Fi 與 NAS ----------------------------------------- */
const char* WIFI_SSID = "你的SSID"; // 2.4GHz!S3 支援 5G 但家讀 AP 多在 2.4G
const char* WIFI_PASS = "你的密碼";
const char* NAS_URL = "http://192.168.1.10:8090/frame"; // 接收器位址
const char* TOKEN = "change-me-8090"; // 與 NAS 端 TL_TOKEN 一致
/* --- 3. 任務參數 --------------------------------------------- */
const uint32_t INTERVALS[] = {10, 30, 60, 300, 600, 1800, 3600}; // 旋鈕檔位(秒)
const uint8_t N_INTERVAL = sizeof(INTERVALS) / sizeof(INTERVALS[0]);
uint8_t iInterval = 1; // 預設 30s
uint32_t T0 = 0; // 任務基準時刻(NVS):幀號=f(時間)
#define PREHEAT 15 // 每幀前丟掉的預熱張數:讓 AE 收敛
#define QUEUE_MAX 25 // 閃存佇列上限(張):丢最舊保新
#define JPEG_QUAL 26 // frame2jpg 品質:VGA 約 30KB/張
Preferences prefs;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
volatile int32_t encPos = 0;
bool taskPaused = false;
/* --- 4. 相機初始化 ------------------------------------------- */
/* initCamera —— 啟動 OV2640 並設 VGA 品質 26、50Hz 燈頻
* 回傳:成功與否(失敗几乎都是 PSRAM 設定或排線)
* 50Hz 燈頻:感光積分時間對齊電網半週期,
* 不設的話燈下拍攝會隨快門時間出現滾動黑條 */
bool initCamera() {
camera_config_t cfg = {};
cfg.pin_d0 = Y2_GPIO_NUM; cfg.pin_d1 = Y3_GPIO_NUM;
cfg.pin_d2 = Y4_GPIO_NUM; cfg.pin_d3 = Y5_GPIO_NUM;
cfg.pin_d4 = Y6_GPIO_NUM; cfg.pin_d5 = Y7_GPIO_NUM;
cfg.pin_d6 = Y8_GPIO_NUM; cfg.pin_d7 = Y9_GPIO_NUM;
cfg.pin_xclk = XCLK_GPIO_NUM; cfg.pin_pclk = PCLK_GPIO_NUM;
cfg.pin_vsync = VSYNC_GPIO_NUM; cfg.pin_href = HREF_GPIO_NUM;
cfg.pin_sccb_sda = SIOD_GPIO_NUM; cfg.pin_sccb_scl = SIOC_GPIO_NUM;
cfg.pin_pwdn = PWDN_GPIO_NUM; cfg.pin_reset = RESET_GPIO_NUM;
cfg.xclk_freq_hz = 20000000;
cfg.pixel_format = PIXFORMAT_JPEG;
cfg.frame_size = FRAMESIZE_VGA; // 640x480:縮時夠用、30KB/張
cfg.jpeg_quality = JPEG_QUAL;
cfg.fb_count = 2; // PSRAM 雙緩衝:拍摄與上傳重疊
cfg.fb_location = CAMERA_FB_IN_PSRAM;
cfg.grab_mode = CAMERA_GRAB_LATEST; // 取「最新」不取「排隊」:倒數到點那張必須是現在的菇
if (esp_camera_init(&cfg) != ESP_OK) return false;
sensor_t* s = esp_camera_sensor_get();
s->set_brightness(s, 0);
s->set_llight_gain(s, true); // 低照增益:菇棚微光場景
s->set_colorbar(s, 0);
s->set_hmirror(s, 0);
s->set_vflip(s, 1); // 本機鏡頭艙倒裝,垂直翻正
s->set_gainceiling(s, (gainceiling_t)4); // 蓋天花板:夜間不讓 AE 把雜訊拉到一片雪花
return true;
}
/* --- 5. 預熱鎖 AE + 取幀 ------------------------------------- */
/* shootLocked —— 連拍 PREHEAT 張丟掉後鎖 AE/AWB,再取正式幀
* 回傳:JPEG 幀(呼叫負責 fb_return);null=相機異常
* 為什麼每幀都預熱:AE 鎖定只能維持到下次解鎖;
* 每幀「穩定→鎖定→捕獲」循環讓每张都是同一段曝光,成片不呼吸 */
camera_fb_t* shootLocked() {
sensor_t* s = esp_camera_sensor_get();
camera_fb_t* fb = nullptr;
for (int i = 0; i < PREHEAT; i++) { // 預熱:AE/白平衡收敛
fb = esp_camera_fb_get();
if (!fb) return nullptr;
esp_camera_fb_return(fb);
}
s->set_exposure_ctrl(s, 0); // 0 = AE OFF:曝光定格在預熱收斂值
s->set_wb_mode(s, 4); // 白平衡鎖「日光」:不用 AWB 就不用怕它演戲
fb = esp_camera_fb_get();
s->set_exposure_ctrl(s, 1); // 拍完立刻解鎖:下一幀重新預熱
s->set_wb_mode(s, 0);
return fb;
}
/* --- 6. 上傳與閃存佇列 --------------------------------------- */
/* uploadFrame —— POST JPEG 到 NAS(header 帶 token 與檔名)
* 回傳:NAS 回應 200 與否(401=token 不符;其他=重試)*/
bool uploadFrame(const uint8_t* jpg, size_t len, uint32_t frameNo) {
HTTPClient http;
if (!http.begin(NAS_URL)) return false;
char name[32];
snprintf(name, sizeof(name), "tl_%06lu.jpg", frameNo);
http.addHeader("Content-Type", "image/jpeg");
http.addHeader("X-Token", TOKEN);
http.addHeader("X-Frame", name);
http.setTimeout(8000); // NAS 卡住 8 秒就放棄,别拖死拍攝節拍
int code = http.POST(jpg, len);
http.end();
return code == 200;
}
/* LittleFS 工具:目錄走 fs::File.openNextFile(Arduino FS API)
* 檔名 q_時10位_幀6位.jpg:字典序=時間序,取最舊不用排序 */
size_t fileSize(const String& p) {
File f = LittleFS.open(p, "rb");
if (!f) return 0;
size_t s = f.size(); f.close(); return s;
}
String oldestQueued() { // 佇列裡最舊的檔名;空佇列回 ""
File dir = LittleFS.open("/q");
if (!dir) return "";
String oldest = "";
for (File e = dir.openNextFile(); e; e = dir.openNextFile())
if (oldest == "" || String(e.name()) < oldest) oldest = e.name();
dir.close();
return oldest; // 字典序最小=時間最舊
}
/* queueSave —— 把 JPEG 存進佇列,超過 QUEUE_MAX 殺最舊
* 縮時要的是「最近的連續」:斷網兩天的代價是丟掉第一天的頭尾 */
bool queueSave(const uint8_t* jpg, size_t len, uint32_t frameNo) {
char path[40];
snprintf(path, sizeof(path), "/q/%010lu_%06lu.jpg",
(uint32_t)time(nullptr), frameNo);
File f = LittleFS.open(path, "wb");
if (!f) return false;
f.write(jpg, len);
f.close();
File dir = LittleFS.open("/q");
int n = 0;
for (File e = dir.openNextFile(); e; e = dir.openNextFile()) n++;
dir.close();
if (n > QUEUE_MAX) { // 丢最舊保新:一次殺一顆就夠
String old = oldestQueued();
if (old != "") LittleFS.remove(("/q/" + old).c_str());
}
return true;
}
/* queueFlushOne —— 補傳一張最舊的;成功才銷毀(寧可重複不可丟幀)
* 回傳:佇列剩餘張數(餵給 OLED 的 q: 欄位)
* 每個週期只補一張:補傳是綠洲不是暴風雨,別把節拍搶光 */
int queueFlushOne() {
String oldest = oldestQueued();
if (oldest == "") return 0;
String path = "/q/" + oldest;
size_t sz = fileSize(path);
if (sz == 0) return 0;
File f = LittleFS.open(path, "rb");
uint8_t* buf = (uint8_t*)malloc(sz);
bool ok = false;
if (buf && f.readBytes((char*)buf, sz) == (int)sz) {
uint32_t frameNo = oldest.substring(11, 17).toInt(); // 跳過 10 位時間戳與底線
ok = uploadFrame(buf, sz, frameNo);
}
f.close(); free(buf);
if (ok) LittleFS.remove(path);
File dir = LittleFS.open("/q");
int n = 0;
for (File e = dir.openNextFile(); e; e = dir.openNextFile()) n++;
dir.close();
return n;
}
/* --- 7. 幀號:由 NTP 時間推導,斷電續號 ------------------------
* frameNo = (now - T0) / interval。重開機不用記「拍到幾張」,
* 時間就是序列號——缺電一小時就缺 120 幀,但號不重、位不亂,
* ffmpeg 端靠 glob 排序天然對齊,缺的幀就是真的沒人活在那段時間 */
uint32_t frameNoNow() {
time_t now = time(nullptr);
if (now < T0 + 100) return 0; // SNTP 沒校好前不給號(0=無效)
return (uint32_t)((now - T0) / INTERVALS[iInterval]);
}
/* --- 8. OLED + 旋鈕 ------------------------------------------- */
bool lastUpOk = true; int queueDepth = 0;
void drawOled(uint32_t frameNo, uint32_t remain) {
oled.clearDisplay();
oled.setTextSize(1); oled.setCursor(0, 0);
oled.print(taskPaused ? "PAUSE" : (lastUpOk ? "OK " : "QUEUE"));
oled.printf(" q:%d\r\n", queueDepth);
oled.printf("f %06lu %lus\r\n", frameNo, (unsigned long)INTERVALS[iInterval]);
if (!taskPaused) oled.printf("next %lus", (unsigned long)remain);
else oled.print("long-press=go");
oled.display();
}
void encA() { if (digitalRead(ENC_B) != digitalRead(ENC_A)) encPos--; else encPos++; }
uint8_t btnState = 0; uint32_t btnDownMs = 0;
bool longFired = false;
void takeFrame(); // 前置宣告:handleButton 引用後方定義
void handleButton() { // 單擊=試拍一張 長壓=暫停/開始
bool now = (digitalRead(ENC_SW) == LOW);
uint32_t ms = millis();
if (now && btnState == 0) { btnState = 1; btnDownMs = ms; longFired = false; }
else if (now && btnState == 1 && !longFired && ms - btnDownMs > 800) {
taskPaused = !taskPaused; longFired = true;
} else if (!now && btnState == 1) {
btnState = 0;
if (!longFired) takeFrame(); // 試拍:對焦/構圖神器,幀號不進號
}
}
/* --- 9. 拍攝週期 --------------------------------------------- */
void takeFrameAt(uint32_t frameNo) {
camera_fb_t* fb = shootLocked();
if (!fb) { lastUpOk = false; return; } // 相機異常:這幀進佇列也救不了,下幀再戰
size_t len = fb->len;
uint8_t* jpg = fb->buf; // JPEG 模式:fb 裡已經是 JPEG,零拷貝
if (WiFi.status() == WL_CONNECTED) {
lastUpOk = uploadFrame(jpg, len, frameNo);
if (!lastUpOk) { // Wi-Fi 通了但 NAS 沒應:同樣進佇列
queueSave(jpg, len, frameNo);
queueDepth++;
}
queueDepth = queueFlushOne(); // 順手補一張佇列最舊
} else {
lastUpOk = false;
queueSave(jpg, len, frameNo);
queueDepth++;
}
esp_camera_fb_return(fb);
}
void takeFrame() { // 手動試拍:獨立計數不佔正式幀號
camera_fb_t* fb = shootLocked();
if (!fb) return;
if (WiFi.status() == WL_CONNECTED)
uploadFrame(fb->buf, fb->len, 999999); // 999999 號=試拍,出片 glob 天然排除
esp_camera_fb_return(fb);
}
/* --- 10. setup / loop ----------------------------------------- */
void setup() {
Serial.begin(115200);
prefs.begin("tl");
T0 = prefs.getULong("t0", 0);
pinMode(ENC_A, INPUT_PULLUP);
pinMode(ENC_B, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENC_A), encA, CHANGE);
Wire.begin(I2C_SDA, I2C_SCL);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
oled.setTextColor(SSD1306_WHITE);
oled.setCursor(0, 0); oled.print("Timelapse boot..."); oled.display();
LittleFS.begin(true, "/"); // 掛在根目錄:/q/... 路徑才對得上
LittleFS.mkdir("/q");
esp_task_wdt_deinit(); // core 已用 5s 預設啟過 TWDT:先關掉
esp_task_wdt_init(120, true); // 重開成 120 秒:卡死 2 分鐘重啟
esp_task_wdt_add(NULL); // 把 loop 所在任務掛上監看
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
configTime(8 * 3600, 0, "pool.ntp.org", "time.nist.gov"); // UTC+8,幀號要靠它
Serial.print("相機啟動");
unsigned long t0 = millis();
while (!initCamera()) { // 相機起不來就重試;重啟也救不了的排線問題交給看門狗
Serial.print('.'); delay(300);
if (millis() - t0 > 15000) ESP.restart();
}
Serial.println(" OK");
Serial.printf("相機啟動耗時 %lums\r\n", millis() - t0); // 正常 800~1500ms;>3s = 排線在哭泣
}
void loop() {
esp_task_wdt_reset(); // 餵狗放最前:能開工本輪=上輪沒卡死
static uint32_t lastSec = 0;
static uint32_t lastFrameNo = 0xFFFFFFFF;
static uint32_t bootWaitDone = 0;
/* --- 校時與 T0 錨定:拿到 NTP 才開工 ------------------------ */
if (!bootWaitDone) {
bool net = (WiFi.status() == WL_CONNECTED) && (time(nullptr) > 1700000000);
if (!net) {
oled.clearDisplay(); oled.setCursor(0, 0);
oled.printf("Net:%s Time:%s", WiFi.status() == WL_CONNECTED ? "ok" : "..",
time(nullptr) > 1700000000 ? "ok" : "..");
oled.display();
delay(500);
return; // 沒時間就沒幀號:寧可晚開始,不可錯開始
}
if (T0 == 0) { // 首次部署:以現在為 t0
T0 = time(nullptr);
prefs.putULong("t0", T0); // 只寫一次:Flash 壽命有限
}
bootWaitDone = millis();
}
handleButton();
int32_t p = encPos; static int32_t lastPos = 0, rem = 0;
int32_t d = p - lastPos + rem; lastPos = p; rem = d % 4;
if (d - rem != 0) { // 隨時可轉旋鈕換間距檔位
iInterval = (iInterval + ((d - rem) > 0 ? 1 : N_INTERVAL - 1)) % N_INTERVAL;
// 換檔重錨 T0:讓「下一幀」正好落在一個新間距之後,幀號連續不跳號
uint32_t done = (lastFrameNo == 0xFFFFFFFF) ? 0 : lastFrameNo + 1;
T0 = time(nullptr) - (int64_t)done * INTERVALS[iInterval];
prefs.putULong("t0", T0); // 旋鈕是低频動作,Flash wear 可忽略
}
uint32_t now = time(nullptr);
uint32_t want = frameNoNow();
if (!taskPaused && want != 0 && want != lastFrameNo) {
// 補拍跳過的幀(最多補 3 張,NAS 重啟/網路卡頓後自動追節拍)
uint32_t back = (lastFrameNo == 0xFFFFFFFF || want < lastFrameNo)
? 0 : min((uint32_t)3, want - lastFrameNo - 1);
for (uint32_t k = want - back; k <= want; k++) {
takeFrameAt(k);
esp_task_wdt_reset(); // 每補一張餵一次狗
}
lastFrameNo = want;
}
if (millis() / 1000 != lastSec) { // 1Hz 刷 OLED
lastSec = millis() / 1000;
uint32_t phase = (now - T0) % INTERVALS[iInterval];
drawOled(lastFrameNo == 0xFFFFFFFF ? 0 : lastFrameNo,
INTERVALS[iInterval] - phase);
}
delay(100);
}
⚠️ 看門狗細節:core 開機已用 5 秒預設啟動 TWDT,直接
init(120) 會被無視——範例用 deinit → init(120) → add(NULL) 重開再掛勾。上傳阻塞極端情況 2 分鐘自動重啟,縮時相機的可靠性是重啟出來的。🗄️ NAS 端:接收器+出片
接收器(Python,丟進 NAS Docker/系統服務)
#!/usr/bin/env python3
# tl_receiver.py — 縮時相機接收端
# 上線:TL_TOKEN=板子同一組 python3 tl_receiver.py
# 存到 frames/tl_000123.jpg;token 不對一律 401,檔名不對一律 400
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
TOKEN = os.environ.get("TL_TOKEN", "change-me-8090")
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frames")
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.headers.get("X-Token") != TOKEN:
self.send_response(401); self.end_headers(); return
name = os.path.basename(self.headers.get("X-Frame", ""))
if not (name.startswith("tl_") and name.endswith(".jpg")):
self.send_response(400); self.end_headers(); return
data = self.rfile.read(int(self.headers.get("Content-Length", "0")))
os.makedirs(OUT, exist_ok=True)
tmp = os.path.join(OUT, name + ".part")
with open(tmp, "wb") as f:
f.write(data)
os.replace(tmp, os.path.join(OUT, name)) # 原子改名:不留半張圖
self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
def log_message(self, *args):
pass # 每 30 秒一則的存取日志對沒人有價值
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8090), Handler).serve_forever()
出片(NAS 上一行命令)
# 24fps 成片:tl_0* .glob 天然排除 999999 號試拍幀,依檔名排序就是時間軸
ffmpeg -framerate 24 -pattern_type glob -i 'frames/tl_0*.jpg' \
-c:v libx264 -pix_fmt yuv420p -crf 20 timelapse_$(date +%m%d).mp4
# 「最新一幀」=即時監視:掛到部落格靜態頁,手機隨時看菇棚
ln -sf "$(ls -1 frames/tl_0*.jpg | sort | tail -1)" latest.jpg
💡
os.replace() 改名是接收端唯一不可省的行:HTTP 斷線斷在半張圖時,下次讀取者永遠讀到完整檔案——縮時相機可以斷線,NAS 上的檔案不可以是半張。🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 相機排線上夾:FPC 掀蓋、金手指朝下(朝感光面)、本體翻正對準——同第 28 篇顯微鏡手法
- 相機艙與三腳架組合:鏡頭凹台防塵朝下;排線出口導引槽鎖線夾,折弯半徑 ≥10mm
- 主板盒走線:USB 應力靴先穿線;14 芯排線與 OLED 線分邊走,不捆綁
- NAS 接收器部署+試拍對焦:30cm 外貼一張小字紙,單擊旋鈕試拍直到 NAS 讀到 999999 號清楚照片
- 試跑 30 分鐘+出片驗收:30 秒間距跑 60 張,ffmpeg 出一支 2.5 秒影片——成片不呼吸即收工
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 拍不到圖(fb_get 失敗) | PSRAM 未開 OPI/排線反插 | 板設定改 OPI PSRAM;排線金手指對準感光面重夾 |
| 上傳一直 401 | 板與 NAS 端 token 不一致 | 兩端同設 TL_TOKEN;注意板子燒錄後不會自己改號 |
| 成片一亮一暗呼吸 | AE 未鎖、燈頻未設 | 確認 shootLocked() 有被調用(試拍对比開燈下差異) |
| 佇列一直滿(q:25) | NAS 關機/接收器沒跑/認證過期 | OLED 按長暂停→查接收器日誌→佇列會自動逐週期補空 |
| 夜間全黑或雪花 | OV2640 低照度天花板 | 加紅光夾燈(菌類不感光周期,人眼也舒服);增益天花板已限不放大雪花 |
| 開機卡在 Net:.. Time:.. | 連的是 5G SSID/DHCP 沒給時間 | S3 連 2.4G;路由器 NTP 放行(幀號靠時間,時間不來不开工) |
| 每隔幾 ten 分鐘缺同一時段 | Wi-Fi 定時重連/AP 頻道掃描 | AP 固定頻道、關頻寬自動切換;補拍機制最多追 3 張 |
🎯 進階延伸
- Deep sleep 版:間距 ≥5 分鐘改 deep sleep 喚醒拍攝,平均電流從 90mA 掉到 <50µA,18650+TP4056 跑兩週——室外菇棚版必点科技
- 雙機位:再焊一台改
tl2_前綴,同一接收器、同一 ffmpeg,成片左右並排(ffmpeg hstack)——頂視+側視看繡球菌,像時間縮時紀錄片 - PIR 事件模式:加 D3AO(~30 元)紅外,平時休眠、貓經過喚醒連拍 30 張——縮時相機兼職抓姦成室貓監視器
- FTP/Nextcloud 版:接收器換 Nextcloud WebDAV,手機 App 直接看相簿——代價:多 100 行程式碼,換 0 行 shell 命令
- AI 出菇偵測:NAS 端每 50 張跑一次開源的 mushroom ripeness 小模型(第 29 篇那套),成熟了 Telegram 推播——縮時相機升格成作物管理員
🎯 結語
第一批繡球菌的縮時成片只有 11 秒:從一枚白色小瘤,到雲霧般撐開整叢菌傘,NAS 裡的 1,847 張 JPEG 把三週壓成一口气看完的時間折疊。3D 列印那台也順手立了功——上週長件打印到第 14 小時開始捲邊,影片回拖到第 11 小時就看見第一絲翹角。「時間不會被錯過第二次」這種話太昂貴,但 720 元買得起的解法是:讓它每個 30 秒幫你眨一次眼。
📦 檔案清單
s3-timelapse-cam/
├── src/
│ ├── s3-timelapse-cam.ino # 本頁完整韌體
│ └── tl_receiver.py # NAS 端接收器
├── systemd/
│ └── tl-receiver.service # 接收器開機自啟單元
├── stl/
│ ├── cam-pod.stl # 相機艙(PETG)
│ ├── lens-hood.stl # 遮光罩
│ ├── board-box.stl # 主板盒
│ ├── tripod-adapter.stl # 1/4" 三腳架轉接件
│ ├── oled-tilt.stl # OLED 斜支架
│ └── knob-cable-clip.stl # 旋鈕帽+排線夾
├── f3d/
│ └── s3-timelapse-cam.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md