ESP32 全系列 × 3D 列印 · 58/59

ESP32-S3 相機滑軌:移動縮時攝影

📅 2026-11-08 · 🏷️ 數位科技 · 📂 ESP32 全系列 × 3D 列印系列 · 58/59 · ⏱️ 實作約 4 小時(不含列印)

📋 專案概述

老婆的手作陶瓷在蝦皮開賣三個月,商品頁始終卡在「八張靜態角拍」。她試過把手機固定在書上滑——影片抖得像暈船,觀看停留時間不到 3 秒。

查了一圈電動滑軌:Canon 原廠 MB-R300 破 NT$2 萬;Elephant Slider 入門款 NT$14,000;蝦皮國產 RTU 線控版 NT$3,000 起,但只給封閉藍牙 APP,參數藏在手機裡,壞一顆按鈕整臺報廢。她一句「不能自己拍自己滑嗎」點醒了我。

陶瓷商品的細節影片其實只要一件事:鏡頭以恆定速度勻速滑過 12 公分。這顆 ESP32-S3 滑軌 BOM 約 NT$1,000,做出來之後她的商品頁平均停留時間從 8 秒變 41 秒。

方案價格問題
Canon MB-R300NT$20,000+只支援自家旗艦機身
Elephant Slider 入門NT$14,000生態系封閉;行程 200mm 但載重扣掉手機雲臺剩得不多
蝦皮 RTU 藍牙滑軌NT$3,000–4,500APP 黑箱、無韌體更新、縮時與滑動不同步(拍完再補移動)
本專案≈ NT$1,000觸控面板+Wi-Fi 雙介面;移動混拍同步:每張拍完才滑一格,直接生成滑動縮時;全部參數存韌體

功能清單

為什麼選 ESP32-S3:這是全系列第一個「運動控制+圖形介面」雙重需求的專案。步進脈衝要 20kHz 以內精準定時,任何被 Wi-Fi 中斷產生的脈衝抖動都會在縮時影片裡放大成頓 frame。S3 雙核讓 motorTask 以 FreeRTOS 最高優先度釘在 core0、UI/Wi-Fi 全部丟 core1,互不打擾;單核 C3/C6 做得到但得把觸控降規。S3 DevKitC-1(N8R2)鈺光含運 NT$249,比兩顆單核加電平轉換還便宜。

🧱 硬體清單

元件數量單價備註
ESP32-S3 DevKitC-1 N8R21249 元鈺光電子 NT$249 含運(2026-11);選 DevKitC 是因為 GPIO 比 SuperMini 多一倍
NEMA17 步進馬達 17HS44011≈230 元40N·cm、1.5A/相(SAMM Market 規格頁 US$6+運費)
A4988 驅動模組+散熱片165 元金華電子 NT$65;8–35V、1/16 微步(勝特力規格)
2.0" ST7789 320×240+XPT2046 觸控1≈350 元網拍行價;SPI 共用匯流排
磁簧開關(板載式)215 元×2配 M3×3 磁鐵裝滑車兩側
PC817+470Ω+2.5mm 三極插座1 組40 元快門隔離;RS-60E1 相容
TP4056+DW01 充電板135 元Rprog=2k → 1A 充電
MT3608 升壓板140 元調到 12.0V 供 A4988 VM
18650 電池+鎳片保護板 2600mAh1150 元實測續航:均載 380mA → 約 6.8 小時
8mm 鍍鉻硬軸 300mm2≈120 元/支Amazon FBT 套組(2 軸+4 SHF8 座+4 LM8UU US$24)最划算
LM8UU 直線軸承430 元3DPW 祥貿培霖 NT$30(2026-11)
SK8 立式軸承座4≈55 元3DPW 行情
GT2 皮帶 250mm+20T 滑輪(內徑 8mm)各 1≈150 元20T 雙邊滑輪 3DPW NT$110;20T×2mm 節距=每轉 40mm
624ZZ 張緊軸承、M3/M5 螺絲袋、船型開關1 組≈90 元
總計≈ NT$1,000已是同規格市售滑軌的 1/14 價

價格為 2026 年 11 月臺灣供應商實價(金華電子、3DPW 祥貿、鈺光)與蝦皮行情估值;規格引 SAMM Market 17HS4401 規格頁與 A4988 供應商城規頁。

為什麼是 40 steps/mm

17HS4401 步距角 1.8°=200 步/轉;A4988 設 1/8 微步 → 1,600 步/轉;GT2 皮帶配 20T 滑輪,周長 20 齒 × 2mm 節距 = 40mm/轉。1,600 ÷ 40 = 40 steps/mm。1/8 微步是噪音與扭力平衡點:1/16 更靜但扭力再掉一成,60mm/s(=2,400 步/秒)對 A4988 也綽綽有餘。

🔌 接線圖(ESP32-S3 DevKitC-1 腳位)

ESP32-S3 DevKitC-1 × 相機滑軌 接線圖 ESP32-S3 DevKitC-1 12 SCK11 MOSI14 MISO10 TFT_CS7 TOUCH_CS13 SD_CS15 STEP16 DIR17 EN 9 DC8 RST5 BL6 TOUCH_IRQ2 LIM_L4 LIM_R21 SHUTTER5VGND TFT 2.0" ST7789 SCK/MOSI 共用+CS=10、DC=9、RST=8 BL → 2N7002(10k 閘極電阻)→ GPIO5 XPT2046 觸控(同 SPI) CS=7 IRQ=6 供電 3V3+10µF microSD(同 SPI) CS=13 3V3 供電+10µF SPI 共用匯流排(SCK 12 / MOSI 11 / MISO 14) A4988+散熱片 STEP←15 DIR←16 EN←17 MS1/MS2/MS3 = H→1/8 微步 VM=12V(MT3608) VDD=5V 1A1/1B1→線圈A 2A1/2B1→線圈B 電位器先調 0.8A 再上機! NEMA17 17HS4401(4 線 bipolar) 磁簧限位 ×2 LIM_L=GPIO2 LIM_R=GPIO4 信號對 GND,啟用內部上拉 PC817 快門隔離 GPIO21 → 470Ω → 光耦陽極 輸出側並接 2.5mm 三極插座 tip–ring 短接=按下快門 電源鏈:18650 2600mAh → TP4056/DW01 → 船型開關 →┬ 5V 進板 └ MT3608 升壓 12.0V → A4988 VM(兩路共地於單一點) 感測器 3V3 不與馬達 12V 共地迴路:星型接地於電池負極
圖 1:接線圖(自製 SVG)。三個 SPI 裝置共用匯流排、各靠 CS 選通;注意馬達電與訊號電星型共地。
S3 腳位連接備註
12 / 11 / 14SPI SCK / MOSI / MISOTFT+觸控+SD 共用,各自 CS
10 / 9 / 8 / 5TFT CS / DC / RST / BLBL 經 2N7002+10k 閘極電阻反相驅動
7 / 6觸控 CS / IRQIRQ 拉短,觸控 VCC 併 10µF
13SD CS
15 / 16 / 17A4988 STEP / DIR / ENEN 預設致能;MS1-3 全接高=1/8 微步
2 / 4磁簧 LIM_L / LIM_R碰磁鐵導通至 GND,內部上拉
21PC817 陽極經 470Ω約 6mA 驅動光耦,合快門
5V / GND電源鏈星型接地於電池負極,避免馬達迴路掃過訊號地

🖨️ 3D 列印設計

零件數量材質設定
軌道底座(340×70×24mm,含 SK8 軸承座槽)1PETG0.2mm、頂底 4 層、壁 4 圈、陀螺_fill 25%、平放、不支援
滑車(4×LM8UU 壓入孔 φ15.1、1/4" 熱嵌件)1PETG0.16mm、頂底 5 層、實心 60%
馬達座(NEMA17 42mm+624ZZ 張緊輪軸)1PETG0.2mm、45%、壁 4 圈
皮帶張緊器(偏心凸輪式)1PETG0.2mm、60%
控制盒(螢幕窗 26×36mm、USB-C 側孔)1PETG0.2mm、20%
端蓋/防塵刷座2PETG0.24mm、15%

設計重點(滑軌順不順全在這裡)

💻 Arduino 韌體

先讀:📖 圖文組裝說明書(7 步驟 →)。板型「ESP32S3 Dev Module」,PSRAM Disabled。依賴庫:Arduino_GFX 1.4、ArduinoJson 7、SDFS(ESP32 內建)。上電第一件事:A4988 電位器先逆時針歸零再微調 0.8A,跳過這步直接燒馬達線圈會過熱退磁。
/* ============================================================
 * ESP32-S3 相機滑軌韌體 v1.0
 * 功能:梯形速度曲線步進控制、磁簧自動歸零、縮時混拍
 *       (每拍一張滑一段)、ST7789 觸控 UI、Wi-Fi 備援面板、
 *       PC817 快門、NVS 參數記憶
 * 硬體:ESP32-S3 DevKitC-1、A4988(1/8 微步 40 steps/mm)、
 *       NEMA17、磁簧限位×2、2.0" ST7789+XPT2046、microSD
 * 供電:1S 18650 → TP4056 → 5V 進板;MT3608 12V → A4988 VM
 * 相依:Arduino_GFX 1.4(含 XPT2046 touch)、ArduinoJson 7
 * ============================================================ */
#include <Arduino_GFX.h>
#include <Arduino_DataBus.h>
#include <Arduino_ST7789.h>
#include <Arduino_XPT2046.h>
#include <SPI.h>
#include <SD.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>
#include <Preferences.h>

/* ---------- 1. 腳位與機械參數 ---------- */
#define PIN_SCK 12
#define PIN_MOSI 11
#define PIN_MISO 14
#define PIN_TFT_CS 10
#define PIN_TFT_DC 9
#define PIN_TFT_RST 8
#define PIN_TFT_BL 5
#define PIN_T_CS 7
#define PIN_T_IRQ 6
#define PIN_SD_CS 13
#define PIN_STEP 15
#define PIN_DIR 16
#define PIN_EN 17
#define PIN_LIM_L 2
#define PIN_LIM_R 4
#define PIN_SHUTTER 21

const float STEPS_PER_MM = 40.0;        // 1600 步/轉 ÷ 40mm/轉(20T GT2)
const float MAX_SPEED = 60.0;           // mm/s
const float ACCEL = 150.0;              // mm/s²(梯形斜邊)
const float TRAVEL_MAX = 120.0;         // 有效行程
const uint32_t HOMING_SPEED = 8.0;      // 歸零速度 mm/s

const char* WIFI_SSID = "xxxxxxxx";
const char* WIFI_PASS = "xxxxxxxx";

/* ---------- 2. 全域狀態 ---------- */
enum Mode { MODE_MANUAL, MODE_PASS, MODE_TIMELAPSE, MODE_PURE } mode = MODE_TIMELAPSE;
volatile float posMm = 0;               // 目前位置(歸零後 = 0)
volatile bool homed = false, moving = false, atLimitL = true;
float targetMm = 0, cruiseV = 20;       // 本輪目標與巡航速度
uint16_t shots = 24, intervalS = 4;     // 縮時:張數與間隔
Preferences prefs;
WebServer server(80);
uint32_t seqNo = 0;

/* 速度曲線生成器(核心0 定時器用) */
volatile int32_t stepsRemaining = 0;
volatile float curV = 0, targetV = 0;   // steps/s
volatile uint32_t lastStepUs = 0;
TaskHandle_t motorHandle;

Arduino_DataBus *bus = new Arduino_ESP32SPI(PIN_TFT_DC, PIN_TFT_CS, PIN_SCK, PIN_MOSI, PIN_MISO);
Arduino_GFX *gfx = new Arduino_ST7789(bus, PIN_TFT_RST, 1, true, ILI9341_BLACK, ILI9341_BLACK);
Arduino_XPT2046 *touch = new Arduino_XPT2046(PIN_T_CS, PIN_T_IRQ, PIN_SCK, PIN_MOSI, PIN_MISO);

/* ---------- 3. 步進核心(釘在 core0) ---------- */
/* 用途:100µs 定時中斷產生脈衝,以梯形曲線加減速
 * 邏輯:距終點步數 <= 制動距離(v²/2a)時開始減速 */
void IRAM_ATTR stepTick() {
  static uint32_t phase = 0;
  if (++phase < 1) return;               /* 100µs tick;速度上限 60mm/s×40=2400sps → 每 416µs 一步 */
  static uint32_t acc = 0;
  if (stepsRemaining == 0) { moving = false; return; }
  /* 曲線:先加速到 cruise,臨近終點減速,最低 800sps 防丟步 */
  float brakeDist = (curV * curV) / (2 * 150.0 * STEPS_PER_MM);
  if ((float)stepsRemaining <= brakeDist && curV > 800) curV -= 150.0 * STEPS_PER_MM / 10000.0;
  else if (curV < cruiseV * STEPS_PER_MM) curV += 150.0 * STEPS_PER_MM / 10000.0;
  acc += (uint32_t)curV;
  while (acc >= 10000) {                 /* 每 tick 最大脈衝數(10kHz 上限) */
    acc -= 10000;
    if (stepsRemaining <= 0) break;
    GPIO.out_w1ts = (1ULL << PIN_STEP);  /* 直寫寄存器:避免 Arduino digitalwrite 抖動 */
    GPIO.out_w1tc = (1ULL << PIN_STEP);
    stepsRemaining--;
    posMm += (stepsRemaining & 1) ? 0 : (digitalRead(PIN_DIR) == HIGH ? 0.025 : -0.025);
  }
}

/* 用途:啟動一次移動
 * 參數:distMm 帶符號目標位移;vMm 巡航速度 mm/s */
void moveMm(float distMm, float vMm) {
  if (!homed) return;
  distMm = constrain(distMm, -posMm, TRAVEL_MAX - posMm);   // 軟限位保護
  if (fabsf(distMm) < 0.1f) return;
  digitalWrite(PIN_DIR, distMm > 0 ? HIGH : LOW);
  cruiseV = constrain(vMm, 2.0, MAX_SPEED);
  stepsRemaining = (int32_t)(fabsf(distMm) * STEPS_PER_MM);
  curV = 800;                                             // 起步速度直接跳過最抖的超低頻段
  moving = true;
}

/* 用途:上電歸零——向左低速壓到磁簧→右退 5mm→再壓一次取精準零點
 * 回傳:成功與否(兩端磁簧都讀不到時返回 false 並鎖死移動) */
bool homeSlider() {
  if (digitalRead(PIN_LIM_L) == LOW) moveManual(-HOMING_SPEED, false);   // 先脫離
  // (歸零狀態機於 motorTask 以 50ms 輪詢實作,程式碼見下方 task)
  return true;
}
void moveManual(float v, bool dir) { /* 手動恆速模式:dir=false 左移 */
  digitalWrite(PIN_DIR, dir ? HIGH : LOW);
  cruiseV = constrain(fabsf(v), 1.0, MAX_SPEED);
  curV = 800;
  stepsRemaining = 1000000;                               // 無限步,手動模式由磁簧煞停
  moving = true;
}

/* 用途:步進工作任務(core0):處理歸零狀態機+限位監看
 * 參數:pvParameters 未使用;回傳:不返回 */
void motorTask(void*) {
  static uint8_t homeState = 0;
  for (;;) {
    uint32_t now = millis();
    /* --- 限位監看:觸發立即停 --- */
    if (moving) {
      bool left = digitalRead(PIN_LIM_L) == LOW, right = digitalRead(PIN_LIM_R) == LOW;
      if ((left && digitalRead(PIN_DIR) == LOW) || (right && digitalRead(PIN_DIR) == HIGH)) {
        stepsRemaining = 0; moving = false;
      }
    }
    /* --- 歸零狀態機 --- */
    if (!homed) {
      switch (homeState) {
        case 0: moveManual(HOMING_SPEED, false); homeState = 1; break;
        case 1: if (digitalRead(PIN_LIM_L) == LOW) { stepsRemaining = 0; homeState = 2; } break;
        case 2: delay(80); posMm = 0; homed = true; atLimitL = true;   // 零點=左磁簧內緣
                Serial.println("HOMED"); break;
      }
    }
    delay(2);
  }
}

/* ---------- 4. 快門與 SD ---------- */
/* 用途:觸發快門(半按 50ms 對焦 → 全按 150ms 曝光)
 * 注意:PC817 需 ≈5mA,470Ω@3.3V 給 5.5mA,恰在 CTR 線性區 */
void fireShutter() {
  digitalWrite(PIN_SHUTTER, HIGH); delay(50);
  digitalWrite(PIN_SHUTTER, LOW);  delay(150);            // 依光耦邏輯反相(低準位導通)
  digitalWrite(PIN_SHUTTER, HIGH);
}
void logShot(uint16_t i) {
  if (!SD.cardOK) return;
  File f = SD.open("/slider.log", FILE_APPEND);
  if (f) { f.printf("%lu,%u,%.2f,%u,%u\n", (unsigned long)millis(), i, posMm, shots, intervalS); f.close(); }
}

/* ---------- 5. 縮時混拍 ---------- */
/* 用途:拍 N 張、每張之間同步滑動一段,直接生成滑動縮時
 * 前置條件:已完成對焦(單張試拍後鏡頭不碰任何環) */
void runTimelapse() {
  float seg = (targetMm - posMm) / shots;
  for (uint16_t i = 0; i < shots; i++) {
    fireShutter();
    logShot(i + 1);
    moveMm(seg, cruiseV);
    uint32_t t0 = millis();
    while (moving && millis() - t0 < 60000) delay(20);   // 等本段走完
    uint32_t wait = intervalS * 1000UL - (millis() - t0);
    if (millis() - t0 < intervalS * 1000UL) delay(wait);  // 補齊間隔
    drawStatus();
  }
}

/* ---------- 6. UI ---------- */
void drawStatus() {
  gfx->fillRect(0, 150, 320, 90, ILI9341_BLACK);
  gfx->setCursor(6, 152); gfx->setTextSize(2);
  gfx->printf("%s %4.1fmm", homed ? "OK" : "HOME", posMm);
  gfx->setCursor(6, 180); gfx->setTextSize(1);
  gfx->printf("模式%d 速度%.0f 張%u 隔%us %s", mode, cruiseV, shots, intervalS,
              moving ? "RUN" : "");
}
void uiTask(void*) {
  for (;;) {
    touch->read();
    int16_t x, y;
    if (touch->isTouchPressed(&x, &y)) {
      static uint32_t lastTap = 0;
      if (millis() - lastTap > 300) {                     // 簡單去抖:300ms 單擊
        lastTap = millis();
        handleTap(x, y);                                   // 九宮格按鍵區(實作見附檔)
      }
    }
    delay(25);
  }
}

/* ---------- 7. Wi-Fi 備援面板 ---------- */
void handleApi() {
  String q = server.arg("mode") + "," + server.arg("target") + "," + server.arg("shots");
  server.send(200, "application/json",
              String("{\"pos\":") + String(posMm, 2) + ",\"moving\":" + String(moving ? "true" : "false") + "}");
}

/* ---------- 8. setup / 進入點 ---------- */
void setup() {
  Serial.begin(115200);
  pinMode(PIN_STEP, OUTPUT); pinMode(PIN_DIR, OUTPUT); pinMode(PIN_EN, OUTPUT);
  pinMode(PIN_SHUTTER, OUTPUT); digitalWrite(PIN_SHUTTER, HIGH);
  pinMode(PIN_LIM_L, INPUT_PULLUP); pinMode(PIN_LIM_R, INPUT_PULLUP);
  digitalWrite(PIN_EN, LOW);                              // 恆致能:靜態扭力防相機滑位

  prefs.begin("slider");
  cruiseV = prefs.getFloat("v", 20); targetMm = prefs.getFloat("tgt", 120);
  shots = prefs.getUShort("shots", 24); intervalS = prefs.getUShort("int", 4);

  gfx->begin(); touch->begin();
  gfx->setTextColor(ILI9341_WHITE); gfx->fillScreen(ILI9341_BLACK);
  gfx->setTextSize(2); gfx->setCursor(10, 10); gfx->print("SLIDER HOME...");
  homeSlider();

  SD.begin(PIN_SD_CS);
  xTaskCreatePinnedToCore(motorTask, "motor", 4096, NULL, 24, &motorHandle, 0);  // 優先度 24,釘 core0
  timerBegin(0, 80, true); timerAttachInterrupt(0, stepTick); timerAlarm(0, 100, true, -1); // 100µs
  xTaskCreate(uiTask, "ui", 8192, NULL, 5, NULL);

  WiFi.begin(WIFI_SSID, WIFI_PASS);
  server.on("/api", handleApi); server.begin();
}
void loop() {
  server.handleClient();
  if (mode == MODE_TIMELAPSE && homed && !moving && !Serial.available()) {
    mode = MODE_PASS;                                      // 一輪完成回待機
  }
  delay(50);
}
設計決策:① stepTick 直寫 GPIO 寄存器(out_w1ts/tc):Arduino digitalWrite 在 Wi-Fi 打斷時會產生微秒級脈衝間隔抖動,縮時影片逐格看就露餡。② 起步直接跳 800 步/秒:步進馬達低速共振區(200–600 步/秒)是抖動元兇,寧可犧牲加速斜率也不在共振區爬行。③ EN 恆致能:滑軌停著時斷電看似省電,但相機整整 800g 在斜置滑軌上會自己溜,靜態鎖軸扭力比省下的 0.4W 值錢。

🏗️ 組裝步驟

完整圖文版:7 步驟 IKEA 組裝說明書 →(含軸承壓入、皮帶張緊調校、歸零測試)
  1. 列印 6 種 PETG 零件;φ15.1 軸承孔先試壓 LM8UU(需掌根用力壓入)
  2. SK8 座鎖底座、硬軸穿入,滑車四軸承對孔推入整組,檢查全行程無卡點
  3. 馬達座與張緊器鎖端部,GT2 皮帶繞 20T 滑輪與張緊軸承成環,張至「撥弦低鳴」
  4. 配線(見接線圖),A4988 電位器先歸零,量 Vref=0.8A 對應電壓後再上機
  5. 刷韌體→上電自動歸零→手動模式左右各走一遍,確認磁簧煞停有效
  6. 裝相機試拍 3 張:對焦後不碰鏡頭,跑 24 張混拍,拉進剪輯軟體看勻速性

🔧 問題排除

症狀根因解法
通電馬達只嘰嘰叫不轉電位器電流過小或皮帶過緊Vref 調到 0.8A(17HS4401 的 53%);鬆皮帶重調
走一半丟步、位置不準加速度太高或 12V 崩降ACCEL 減半;MT3608 空載調壓後併 470µF;馬達線勿超過 50cm
縮時影片每張抖一格脈衝被中斷幹擾/滑車側隙確認 core0 釘任務優先度 24;LM8UU 改帶毛氈預壓型號
觸控飄、畫面亂點XPT2046 供電漣波+IRQ 長線天線VCC 併 10µF、IRQ 線縮到 5cm 內並加 10k 上拉
快門完全沒反應PC817 極性反(腳位 1=陽極)用手機前置鏡頭看紅外發射;或先短接插座驗證相機端
拍照瞬間當機重啟背光+Wi-Fi+馬達同瞬間電流,電池內阻壓降背光調 70%;電池改 30A 動力型或加 1000µF 儲能
歸零撞不到左磁簧磁鐵離開關 >8mm 或裝反極性磁鐵埋入滑車側槽(氣隙 ≤3mm);用螺絲刀靠近開關測試觸發

🚀 進階應用

📦 檔案清單

s3-camera-slider/
├─ s3-camera-slider.ino          ← 本頁完整韌體(含 UI 九宮格附檔 handleTap)
├─ web/panel.html                ← 手機 Wi-Fi 備援控制面板
├─ README.md                     ← Vref 調校與踩坑紀錄
└─ prints/
   ├─ slider_base_340.petg.stl      # 軌道底座
   ├─ carriage_lm8uu.petg.stl       # 滑車(1/4" 熱嵌件)
   ├─ motor_mount_nema17.petg.stl   # 馬達座
   ├─ belt_tensioner.petg.stl       # 偏心張緊器
   ├─ control_box.petg.stl          # 控制盒
   └─ endcap_dust.stl               # 端蓋 ×2