老婆的手作陶瓷在蝦皮開賣三個月,商品頁始終卡在「八張靜態角拍」。她試過把手機固定在書上滑——影片抖得像暈船,觀看停留時間不到 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-R300 | NT$20,000+ | 只支援自家旗艦機身 |
| Elephant Slider 入門 | NT$14,000 | 生態系封閉;行程 200mm 但載重扣掉手機雲臺剩得不多 |
| 蝦皮 RTU 藍牙滑軌 | NT$3,000–4,500 | APP 黑箱、無韌體更新、縮時與滑動不同步(拍完再補移動) |
| 本專案 | ≈ NT$1,000 | 觸控面板+Wi-Fi 雙介面;移動混拍同步:每張拍完才滑一格,直接生成滑動縮時;全部參數存韌體 |
motorTask 以 FreeRTOS 最高優先度釘在 core0、UI/Wi-Fi 全部丟 core1,互不打擾;單核 C3/C6 做得到但得把觸控降規。S3 DevKitC-1(N8R2)鈺光含運 NT$249,比兩顆單核加電平轉換還便宜。| 元件 | 數量 | 單價 | 備註 |
|---|---|---|---|
| ESP32-S3 DevKitC-1 N8R2 | 1 | 249 元 | 鈺光電子 NT$249 含運(2026-11);選 DevKitC 是因為 GPIO 比 SuperMini 多一倍 |
| NEMA17 步進馬達 17HS4401 | 1 | ≈230 元 | 40N·cm、1.5A/相(SAMM Market 規格頁 US$6+運費) |
| A4988 驅動模組+散熱片 | 1 | 65 元 | 金華電子 NT$65;8–35V、1/16 微步(勝特力規格) |
| 2.0" ST7789 320×240+XPT2046 觸控 | 1 | ≈350 元 | 網拍行價;SPI 共用匯流排 |
| 磁簧開關(板載式) | 2 | 15 元×2 | 配 M3×3 磁鐵裝滑車兩側 |
| PC817+470Ω+2.5mm 三極插座 | 1 組 | 40 元 | 快門隔離;RS-60E1 相容 |
| TP4056+DW01 充電板 | 1 | 35 元 | Rprog=2k → 1A 充電 |
| MT3608 升壓板 | 1 | 40 元 | 調到 12.0V 供 A4988 VM |
| 18650 電池+鎳片保護板 2600mAh | 1 | 150 元 | 實測續航:均載 380mA → 約 6.8 小時 |
| 8mm 鍍鉻硬軸 300mm | 2 | ≈120 元/支 | Amazon FBT 套組(2 軸+4 SHF8 座+4 LM8UU US$24)最划算 |
| LM8UU 直線軸承 | 4 | 30 元 | 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 供應商城規頁。
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 也綽綽有餘。
| S3 腳位 | 連接 | 備註 |
|---|---|---|
| 12 / 11 / 14 | SPI SCK / MOSI / MISO | TFT+觸控+SD 共用,各自 CS |
| 10 / 9 / 8 / 5 | TFT CS / DC / RST / BL | BL 經 2N7002+10k 閘極電阻反相驅動 |
| 7 / 6 | 觸控 CS / IRQ | IRQ 拉短,觸控 VCC 併 10µF |
| 13 | SD CS | |
| 15 / 16 / 17 | A4988 STEP / DIR / EN | EN 預設致能;MS1-3 全接高=1/8 微步 |
| 2 / 4 | 磁簧 LIM_L / LIM_R | 碰磁鐵導通至 GND,內部上拉 |
| 21 | PC817 陽極經 470Ω | 約 6mA 驅動光耦,合快門 |
| 5V / GND | 電源鏈 | 星型接地於電池負極,避免馬達迴路掃過訊號地 |
| 零件 | 數量 | 材質 | 設定 |
|---|---|---|---|
| 軌道底座(340×70×24mm,含 SK8 軸承座槽) | 1 | PETG | 0.2mm、頂底 4 層、壁 4 圈、陀螺_fill 25%、平放、不支援 |
| 滑車(4×LM8UU 壓入孔 φ15.1、1/4" 熱嵌件) | 1 | PETG | 0.16mm、頂底 5 層、實心 60% |
| 馬達座(NEMA17 42mm+624ZZ 張緊輪軸) | 1 | PETG | 0.2mm、45%、壁 4 圈 |
| 皮帶張緊器(偏心凸輪式) | 1 | PETG | 0.2mm、60% |
| 控制盒(螢幕窗 26×36mm、USB-C 側孔) | 1 | PETG | 0.2mm、20% |
| 端蓋/防塵刷座 | 2 | PETG | 0.24mm、15% |
/* ============================================================
* 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 值錢。| 症狀 | 根因 | 解法 |
|---|---|---|
| 通電馬達只嘰嘰叫不轉 | 電位器電流過小或皮帶過緊 | 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