📋 專案概述

上個月第一批繡球菌,出菇那天早上興奮起床要拍照——結果「三天前從哪天開始冒蕾、多久長出一指」完全沒紀錄。3D 列印也一樣:20 小時的長件堵頭都是隔天收件才發現,失敗的那 900g 材料直接當學費。生長與失敗都是時間軸上的事,而人不能 24 小時瞪著。

時間紀錄器這個位置上的現有選項,每一個都有代價:

  1. GoPro 縮時:4K 檔案地獄(一張 8MB、一天兩萬張起跳),每 12 小時搶著充電,記憶體卡兩週就滿。
  2. 手機架著拍:兩小時後螢幕降亮度、App 被系統回收殺掉,第二天只剩 11 張照片和過熱的電池。
  3. 市售室內縮時機:NT$2,900 起跳,附一個要註冊帳號的雲 App,檔案壓到沒朋友,訂閱到期畫面打結。

這台 NT$720 的桌上型縮時攝影機把問題縮到最小:每 N 秒拍一張、直接丟家中 NAS,功能清單:

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 N16R81~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

🔌 接線圖

ESP32-S3 DevKitC-1 N16R8 USB-C 5V/2A 15 XCLK 1 SIOD 2 SIOC 3/4/5 V/H/P 6-13 D0-D7 40 SDA 39 SCL 16/17/18 旋鈕 3V3 禁腳↓ 19/20/26-32 OV2640(FPC 轉接板) 排線 ≤15cm 是鐵律 14 芯 DVP 排線 f 00412 27s ok q:0 OLED(SDA40/SCL39) 藍 I2C KY-040 → 16/17/18 黃 編碼器
▲ 相機是 14 芯的「大排線怪兽」,其餘兩組都是三線小角色。禁接腳(19/20 USB、26-32 Flash)在板子右下角列得清清楚楚。

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。寫腳位表時先把禁接區畫掉再分配。

🎯 它的日常:拍攝管線長什麼樣

① 倒數中 OLED 跳秒 (可插播試拍) ② 預熱鎖 AE 連拍 15 張丟掉 曝光/白平衡定格 ③ 取幀 JPEG VGA q26 ~30KB 進 PSRAM ④a 上傳 NAS POST+token 200 OK ④b 進佇列 LittleFS 上限 25 張 下一週期先補傳佇列(丟最舊保新) NAS frames/ ffmpeg 出片
▲ 每個拍攝週期都是「補佇列 → 預熱 → 取幀 → 上傳」四步曲;斷網期間自動退化成「補佇列 → 預熱 → 取幀 → 進佇列」。

🎯 縮時殺手「呼吸現象」與本機的處方

✗ 未鎖 AE:成片「呼吸」 每幀各自測光:一團菌刺進畫面 → 全場變暗 → 下一幀又拉亮 ✓ 預熱鎖 AE+50Hz 燈頻 曝光定格:只有「被攝物」在變,亮度不會演自己 處方
▲ 左邊每條凹凸都是 Auto Exposure 在「好心幫倒忙」。處方:開機連拍 15 張讓 AE 穩定後整體鎖定,再把燈頻設 50Hz 對上台灣電網。

🖨️ 3D 列印設計

零件清單

零件數量材質建議列印設定備註
相機艙1PETG0.2mm/3 壁/20%鏡頭孔 φ16mm;艙內全黑化(消光黑噴漆或貼黑紙)
遮光罩1PLA0.2mm/2 壁/10%長 3 倍口徑;逆光場景救星,可拆
主板盒1PLA0.2mm/3 壁/15%USB-C 側出+排線出口;不封死(相機要散熱)
三腳架轉接件1PETG0.2mm/4 壁/60%1/4" 螺孔嵌銅螺母;相機艙側鎖
OLED 斜支架1PLA0.2mm/3 壁/15%30° 仰角,站立視線可讀
旋鈕帽+線夾各 1PLA0.16mm/4 壁D 型軸;線夾收 14 芯排線拐弯半徑 ≥10mm

設計要點

  1. 鏡頭凹台朝下防塵:相機艙立放時鏡頭陷進凹台 2mm——落在桌面上的灰塵永遠碰不到鏡片,菇棚的孢子也一樣
  2. 排線是脆的:FPC 排線折弯半徑 ≥10mm、出口做導引槽鎖線夾——縮時相機一放一個月,應力集中處遲早斷線
  3. 艙內消光:PETG 半透明會漏光進鏡頭形成霧翳;內壁消光黑處理,鏡頭孔內緣加 2mm 遮光簷
  4. 散熱不封死:S3+Wi-Fi+相機連續動作 ~1.5W,主板盒留上下對流孔;夏天悶在菇棚 35°C,封閉殼會熱到降頻跳幀
  5. 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 組裝說明書
  1. 相機排線上夾:FPC 掀蓋、金手指朝下(朝感光面)、本體翻正對準——同第 28 篇顯微鏡手法
  2. 相機艙與三腳架組合:鏡頭凹台防塵朝下;排線出口導引槽鎖線夾,折弯半徑 ≥10mm
  3. 主板盒走線:USB 應力靴先穿線;14 芯排線與 OLED 線分邊走,不捆綁
  4. NAS 接收器部署+試拍對焦:30cm 外貼一張小字紙,單擊旋鈕試拍直到 NAS 讀到 999999 號清楚照片
  5. 試跑 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 張

🎯 進階延伸

🎯 結語

第一批繡球菌的縮時成片只有 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
← 上一篇:ESP32-C3 迷你桌上型示波器 📚 系列索引 下一篇:ESP32-S3 電子墨水桌面資訊卡 →