ESP32 全系列 × 3D 列印實作系列
🎤 ESP32-S2 手勢簡報筆 — 揮手翻頁的 NT$260 空氣遙控器
📅 2026-10-28 · 🏷️ 數位科技 · 📂 ESP32 全系列 × 3D 列印系列 · 30/52 · ⏱️ 實作約 1.5 小時(不含列印)
📋 專案概述
上週部門季報,輪到我們組。同事阿立把公司那支邏輯題簡報筆帶來救場——結果那支筆用的是2.4G 專精接收器,插在上一位報告人的筆電上沒拔。他站在台上舉著筆對投影幕乾揮,台下三百雙眼睛看他表演默劇。最後他只能一路小跑步回座位:簡報筆最尷尬的瞬間,是你人站在台上、筆在手上、靈魂在插接收器的那台筆電旁邊。
市售簡報筆兩類,各有死法:
- 專精接收器款(NT$600~1,500):體積小容易插著被拔走、遺失接收器整支變按鈴、有些只支援自家驅動。
- 藍牙款(NT$900 起):免接收器了,但要配對、會斷線重連、Windows 睡眠醒來後「請重新配對你的裝置」——簡報開始前 30 秒你不想做這种事。
解法其實一直躺在 ESP32-S2 的腳位表上:它內建 USB OTG,可以直接把自己模擬成一支 USB 鍵盤。插上一個 20 元的 USB-A 公頭 dongle,任何電腦插上就是「有鍵盤」——免驅動、免配對、免接收器協議,Windows/macOS/Linux/Chromebook 全通。再配一颗 28 元的六軸慣性感測,把手勢寫成按鍵事件,就得到一支「揮手翻頁、連搖三下黑燈」的空氣簡報筆。全部材料 NT$262:
- USB HID 免驅動:主機 dongle 是標準 USB 鍵盤,電腦把它們當出聲鍵盤,插了就能用
- 實體按鍵優先:兩顆按鍵=上一頁/下一頁(99% 的時間靠它們,零延遲零誤判)
- 手勢加料:手腕向左甩=下一頁、向右甩=上一頁、連續搖三下=黑燈/回復(B 鍵)、長按 2 秒=開始/結束簡報(F5/Esc)
- 開機自校準:把簡報筆放上桌面開機,自動記錄你的「 resting 手姿」,斜 30° 握著也不會誤觸
- 3.7V 鋰聚合物+TP4056:深度睡眠 20µA 級,講一週、充一次;USB-C 補電 40 分鐘回滿
- 3D 列印筆身+充電底座:底座同時是「防丟座」,開會前 dongle 插在電腦、筆插在底座,二者都在視線內
成本算盤:羅技 R500 約 NT$1,190,本專案 262 元,還多做得到「手勢」這件事——羅技要你有這種功能請買 NT$2,500 的 Spotlight 版。更實用的是:因為它是「鍵盤」,你可以用 AutoHotkey 把它改造成任何巨集扳機(按下去 = 開啟你的簡報模板),這是封閉協議題簡報筆永遠給不了的自由度。
🔬 為什麼選 ESP32-S2(選型說明)
本專案的硬需求只有一條:USB Device(OTG)模式跑 HID。家族裡有原生 USB 的型號不少,但定位各異:
| 型號 | USB 能力 | 無線 | 板價 | 適合度 |
| ESP32-S2(本篇) | USB-OTG(可當 Device) | Wi-Fi 4 | ~NT$75 | ⭐⭐⭐⭐⭐ 家族第一款 OTG 晶片、待機最省的 HID 平台 |
| ESP32-S3 | USB-OTG | Wi-Fi+BLE | ~NT$130 | ⭐⭐⭐⭐ 同能力但多付 BLE 錢,本專案用不到 |
| ESP32-C3 / C6 / C61 | 只有 USB-Serial-JTAG,不能當 USB Device | Wi-Fi 等 | ~NT$40–120 | ❌ 模擬不了鍵盤,直接出局 |
| 經典 ESP32 | 無 USB | Wi-Fi+BT | ~NT$110 | ❌ 得外加 CH9102 之類,做不了 HID |
S2 的取舍很甜:它是「能做 USB HID 的型號」裡面最便宜、待機最省的(深度睡眠 ~20µA 級,比 S3 更低一档);簡報筆不需要 BLE、不需要第二顆晶片。慣性手勢那顆 MPU6050 走 I2C,S2 的預設腳位 GPIO8/9 即插即用。Arduino 支援:arduino-esp32 core ≥ 3.0,開發板選 ESP32S2 Dev Module,USB 模式選 Hardware CDC and JTAG 之上的原生 HID(本程式用 USBHIDKeyboard,core 內建)。
⚠️ S2 專屬眉角:S2 的 GPIO45/46 是 VDD_SPI 電壓 strapping 腳,接了外部電路會讓板子燒錄模式錯亂;GPIO33~37 在部分模組被 PSRAM/Flash 佔用。本專案腳位全部避開,以 S2 SuperMini 引出的 GPIO1~14 為準。
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
| 主控板 | ESP32-S2 SuperMini | 1 | ~75 元 | 需可進入 ROM 燒錄模式(按 BOOT+RST)各廠板皆可 |
| 慣性感測 | MPU6050 模組(Gyro-521 亦可) | 1 | ~28 元 | 本程式直接讀寄存器,不用 DMP 韌體 |
| 鋰電 | 鋰聚合物 502030(3.7V 400mAh) | 1 | ~45 元 | 6mm 厚以內,筆身塞得下 |
| 充電 | TP4056+DW01 保護二合一板 | 1 | ~10 元 | Rprog 10k ≈ 125mA,小電池安全慢充 |
| 按鍵 | 6×6 輕觸開關 | 2 | ~4 元 | 上一頁/下一頁 |
| 電源開 | MSK-12C02 側滑開關 | 1 | ~8 元 | 切斷電池,久放不吃電 |
| 指示 | 3mm LED + 1kΩ | 1 | ~3 元 | 手勢確認閃碼 |
| 隱藏連線 | USB-C 公轉 USB-A 母縮回轉接線 | 1 | ~48 元 | 藏在筆身裡,需要時拉出來直插筆電——本專案的「可靠性底牌」,見下方架構說明 |
| 導線/固定 | 30AWG 矽膠線、雙面膠、尼龍束帶 | 1 組 | ~20 元 | 矽膠線軟、不易在筆身內折斷 |
| 合計 | | | ~262 元 | 不含列印耗材(筆身+底座約 12g PLA) |
「無線」的代價與本專案的解法(最關鍵的一段設計)
USB HID 的代價很直白:它是或有線的。要嘛接受藍牙款的配對儀式與斷線重連,要嘛——承認「最後一段總得有個接頭」,然後把它做好。本專案的解法是筆尾內藏一支縮回式轉接線(舊式彈簧縮回手機轉接線同款思路):
- 上場:拉出縮回線插進筆電 USB-A,1 秒枚舉完成,全系統免驅動——此時簡報筆=一支長得像筆的鍵盤,手勢與按鍵全部走這條線回電腦。
- 練習/充電:線收回頭裡,USB-C 接充電器即可;深度睡眠待機一週不充。
少焊一顆晶片、少寫一份韌體、零配對、零斷線——把複雜度留給列印件,把可靠性留給銅線,這是本系列一貫的工法。
🔌 接線圖
▲ 全機 8 支線:I2C 兩支、供電兩支、按鍵兩支(另一腳共 GND)、LED 一支。電池由 TP4056 板接入背板 VBAT 焊盤,經板載穩壓供電,見下表。
詳細接線
| ESP32-S2 腳位 | 連接 | 說明 |
| GPIO8 (SDA) | MPU6050 SDA | S2 Wire 預設腳,免引腳重新配置 |
| GPIO9 (SCL) | MPU6050 SCL | 模組自带 10k 上拉,不必外加 |
| GPIO4 | 按鍵 A → GND | 啟用內部上拉;按鍵的另一腳接 GND |
| GPIO5 | 按鍵 B → GND | 同上;兩鍵同按 2 秒=手動重校準 |
| GPIO7 | LED(串 1kΩ)→ GND | 手勢成功閃一下:台上確認「有收到」比什麼都重要 |
| GPIO3 | 電池電壓分壓(100k+100k) | S2 ADC1 範圍,低於 3.45V 開機閃紅提示充電 |
| 3V3 | MPU6050 VCC | 模組板載 LDO,3V3 供電量測最準 |
| GND | 全部 GND 共地 | 必接,I2C 不共地只會印出亂碼 |
| VBAT(背板焊盤) | TP4056 板 B+ | 繞過板載 LDO 直供 3.3V?不——鋰電 4.2V 進 VBAT 經板子降壓,效率比 LDO 高一截 |
⚠️ TP4056 板的 B-/B+ 接反會直接短路鋰電(三秒內發燙鼓包)。焊完先量 B-B+ 電壓再焊電池線;充電時 USB-C 插著,USB-C 同時供系統電力。
ASCII 對照表(SuperMini 絲印為準)
ESP32-S2 SuperMini MPU6050 / 週邊
┌─────────────┐
│ USB-C(尾端)│←── 縮回轉接線(筆尾出線)→ 筆電 USB-A
│ 3V3 │──→ MPU6050 VCC
│ GND │──→ MPU6050 GND、按鍵冷端、LED 陰極
│ GPIO8 │──→ MPU6050 SDA
│ GPIO9 │──→ MPU6050 SCL
│ GPIO3 │←── 電池分壓中點(100k+100k)
│ GPIO4 │←── 按鍵A(熱端,冷端→GND)
│ GPIO5 │←── 按鍵B(熱端,冷端→GND)
│ GPIO7 │──→ 1kΩ → LED+ → GND
│ VBAT(背面) │──→ TP4056 板 B+(B−→GND 匯流排)
└─────────────┘
避開:GPIO0(BOOT)、GPIO45/46(VDD_SPI strapping)、GPIO33~37(Flash/PSRAM)
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
| 筆身上殼(按鍵窗) | 1 | PLA | 深綠 | ~1.5 hr |
| 筆身下殼(電池艙) | 1 | PLA | 深綠 | ~2 hr |
| 按鍵帽 ×2 | 2 | PLA | 淺綠撞色 | ~8 min |
| 充電底座(帶收線槽) | 1 | PLA | 灰 | ~1.2 hr |
| 筆尾縮回線壓蓋 | 1 | PETG | 黑 | ~10 min |
列印設定
- 材質:PLA(室內用品);縮回線壓蓋用 PETG 抗反覆彎折疲勞
- 填滿:外殼 15% 同心圓(握感輕);壓蓋 100%
- 層厚:0.2mm,外殼頂部面 3 層實心(按鍵窗不吃支撐)
- 方向:筆身「面朝下」平印,按鍵窗免支撐;握彎弧度靠 12° 底墊完成,不靠支撐
設計要點
- 指窩防滑槽:上殼拇指區三道 0.6mm 深凹槽,簡報手汗也不打滑——這比任何 RGB 燈都實用
- 縮回線出線槽:筆尾 5×3mm 槽+PETG 壓蓋鎖住轉接線根部,插拔受力在殼不在焊點(縮回線斷裂 99% 發生在根部)
- 感測器艙與質心:MPU6050 鎖在筆身最前端、電池在尾——甩手勢時前端線性加速度最大,手勢信噪比直接翻倍;模組下方留 2mm 空腔隔絕手溫漂移
- 底座防倒配重槽:底座底部 30×30×8mm 槽放一枚舊 18650 或硬幣+雙面膠,桌面撞不倒
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」。開發板選 ESP32S2 Dev Module,USB 選 Hardware CDC and JTAG 以外的預設(本程式 HID 自己接管 USB),cores ≥ 3.0。
/* ============================================================
* ESP32-S2 手勢簡報筆 | Gesture Presenter
* ------------------------------------------------------------
* 功能:USB HID 鍵盤模擬(翻頁/F5/Esc/B)+ MPU6050 甩手手勢
* 開機桌面自校準 resting 手姿、低電偵測、深度睡眠
* 硬體:ESP32-S2 SuperMini + MPU6050(I2C 8/9) + 按鍵 4/5
* + LED 7 + 電池分壓 GPIO3
* 供電:3.7V LiPo via VBAT,USB-C 充電與有線 HID 共用
* 相依:arduino-esp32 core ≥3.0(USBHIDKeyboard 內建)
* ============================================================ */
#include <Wire.h>
#include "USB.h"
#include "USBHIDKeyboard.h"
#include <driver/gpio.h>
#include <esp_sleep.h>
USBHIDKeyboard Keyboard;
/* ------------------------------------------------------------
* 1. 腳位與時序常數
* ------------------------------------------------------------ */
const int PIN_LED = 7; // 手勢回饋燈:台上要「看得見」鍵盤收到了
const int PIN_BTN_A = 4; // 前一頁(實體鍵永遠優先於手勢)
const int PIN_BTN_B = 5; // 下一頁
const int PIN_VBAT = 3; // 電池分壓中點(100k+100k)
const float VBAT_DIV = 2.0f; // 分壓比:ADC 讀值 ×2 = 實電壓
const uint8_t MPU_ADDR = 0x68; // AD0 接地時的預設地址
const unsigned long GESTURE_COOLDOWN_MS = 900; // 一次手勢後的冷卻:防止甩一次的振盪被數成兩次
const unsigned long SHAKE_WINDOW_MS = 1600; // 「連搖三下」的計數視窗
const float SWIPE_G = 0.55f; // 甩動峰值閾值(g):約 0.55g 邊界,低於日常走動的 0.2g 抖動
const float SHAKE_G = 1.9f; // 搖晃判定閾值:故意高於甩頁閾值,兩手勢不打架
const int IDLE_SLEEP_S = 90; // 靜置 90 秒進深度睡眠(按鍵喚醒;喚醒≈2 秒,開會前預先推一下鍵)
/* ------------------------------------------------------------
* 2. 校準後的「resting 手姿」基準(開機時桌面記錄)
* 記錄 2 秒平均的重力向量,之後只比較「相對傾角變化」——
* 這樣斜 30° 握著不會誤觸,躺平放桌上也不會
* ------------------------------------------------------------ */
float refAx = 0, refAy = 0, refAz = 1; // 預設值防止校準失敗時除零
/* 手勢狀態機 */
unsigned long cooldownUntil = 0;
unsigned long shakeTimes[4] = {0, 0, 0, 0}; // 最近四次搖晃時間戳(環形)
int shakeHead = 0;
/* ------------------------------------------------------------
* mpuWrite / mpuRead —— MPU6050 寄存器讀寫薄封裝
* 為什麼不用現成函式庫:本專案只需 6 個寄存器,
* 自己發 I2C 少了 40KB flash 與一堆版本相容性地雷
* ------------------------------------------------------------ */
void mpuWrite(uint8_t reg, uint8_t val) {
Wire.beginTransmission(MPU_ADDR);
Wire.write(reg); Wire.write(val);
Wire.endTransmission();
}
int16_t mpuRead16(uint8_t reg) { // 回傳:有號 16-bit(高字节在前)
Wire.beginTransmission(MPU_ADDR);
Wire.write(reg);
Wire.endTransmission(false); // repeated start,省一次總線復位
Wire.requestFrom((uint8_t)MPU_ADDR, (uint8_t)2);
return (int16_t)((Wire.read() << 8) | Wire.read());
}
/* ------------------------------------------------------------
* mpuInit —— 初始化 MPU6050
* ±4g / ±500dps:±4g 讓「甩」的峰值落在 16-bit 量程前半段,
* 飽和不會吃掉手勢波峰;500dps 對甩手腕(典型 <250dps)留頭
* ------------------------------------------------------------ */
bool mpuInit() {
Wire.begin(8, 9); // SDA=GPIO8, SCL=GPIO9(S2 預設)
delay(50);
mpuWrite(0x6B, 0x80); // 復位
delay(100);
mpuWrite(0x6B, 0x00); // 解睡眠(醒來預設是睡眠!忘了這步讀數全零)
mpuWrite(0x1C, 0x08); // ±4g
mpuWrite(0x1B, 0x08); // ±500dps
mpuWrite(0x1A, 0x05); // DLPF 44Hz:甩手能量在 5~15Hz,低通先削一半震動雜訊
Wire.beginTransmission(MPU_ADDR); // 存在性探測:讀 WHO_AM_I
Wire.write(0x75);
Wire.endTransmission(false);
Wire.requestFrom((uint8_t)MPU_ADDR, (uint8_t)1);
uint8_t who = Wire.available() ? Wire.read() : 0;
return (who == 0x68 || who == 0x98 || who == 0x72); // 6050/9250 家族都能過
}
/* ------------------------------------------------------------
* readAccelG —— 讀三軸加速度(g)
* 參數:x/y/z 輸出引號
* 為什麼每次現讀而非中斷緩衝:20ms 輪詢已綽綽有餘,
* 省下的 INT 腳留給日後喚醒改版
* ------------------------------------------------------------ */
void readAccelG(float& x, float& y, float& z) {
x = mpuRead16(0x3B) / 8192.0f; // ±4g → 8192 LSB/g
y = mpuRead16(0x3D) / 8192.0f;
z = mpuRead16(0x3F) / 8192.0f;
}
/* ------------------------------------------------------------
* calibrateRest —— 開機校準:2 秒平均取 resting 重力向量
* 前提:開機時筆是平放/自然握持且静止的——開機即甩必誤判,
* 所以校準後 3 秒內不啟用檢測(給手離開筆的時間)
* ------------------------------------------------------------ */
void calibrateRest() {
double sx = 0, sy = 0, sz = 0; int n = 0;
for (int i = 0; i < 100; i++) {
float x, y, z; readAccelG(x, y, z);
sx += x; sy += y; sz += z; n++;
delay(20);
}
refAx = sx / n; refAy = sy / n; refAz = sz / n;
Serial.printf("[CAL] rest = (%.2f, %.2f, %.2f)g\n", refAx, refAy, refAz);
}
/* ------------------------------------------------------------
* sendKey —— 按鍵事件統一出口(含 LED 回饋與序列埠除錯)
* 參數:kc = Arduino key code;holdMs = 按住時長
* 為什麼要 holdMs:簡報軟體(PPT/Keynote)對「按一下」需要
* press+release 完整週期;F5 偶爾吃掉 release 會卡在全螢幕
* ------------------------------------------------------------ */
void sendKey(uint8_t kc, uint16_t holdMs = 60) {
Keyboard.press(kc);
delay(holdMs);
Keyboard.release(kc);
digitalWrite(PIN_LED, HIGH); // 視覺確認:0.06 秒的燈,台下看不见、台上看得見
delay(60);
digitalWrite(PIN_LED, LOW);
Serial.printf("[KEY] 0x%02X\n", kc);
}
/* ------------------------------------------------------------
* pressAndHoldKey —— 長按事件(開始/結束簡報用,按住語義不同)
* ------------------------------------------------------------ */
void pressAndHoldKey(uint8_t kc, uint16_t ms) {
Keyboard.press(kc);
delay(ms);
Keyboard.release(kc);
}
/* ------------------------------------------------------------
* handleButtons —— 實體按鍵:去彈跳 40ms + 雙鍵長按校準
* 回傳:true = 本迴圈已被按鍵消耗(手勢檢測讓位,雙保險防誤觸)
* ------------------------------------------------------------ */
bool handleButtons() {
static bool lastA = HIGH, lastB = HIGH;
static unsigned long tA = 0, tB = 0, holdStart = 0;
bool acted = false;
bool a = digitalRead(PIN_BTN_A);
bool b = digitalRead(PIN_BTN_B);
if (a != lastA) { tA = millis(); lastA = a; } // 先記錄邊界,下面才判穩定
if (b != lastB) { tB = millis(); lastB = b; }
if (millis() - tA > 40 && lastA == LOW) { // 按穩 40ms 才算按下(彈跳filt)
if (lastB == LOW) { // 雙鍵同按 → 重校準
if (millis() - holdStart > 2000) { calibrateRest(); holdStart = millis() + 999999UL; }
} else {
sendKey(KEY_LEFT_ARROW); // 前一頁
acted = true;
}
if (a == HIGH) holdStart = 0;
}
if (millis() - tB > 40 && lastB == LOW && lastA == HIGH) {
unsigned long down = tB;
while (digitalRead(PIN_BTN_B) == LOW && millis() - down < 2100) delay(5);
if (millis() - down >= 2000) pressAndHoldKey(KEY_F5, 120); // 長按 2 秒 → 開始簡報
else sendKey(KEY_RIGHT_ARROW); // 短按 → 下一頁
acted = true;
}
return acted;
}
/* ------------------------------------------------------------
* detectGesture —— 手勢狀態機
* 原理:以校準向量為參考,水平面內(左右甩主要激發 x 軸)
* 的偏移超過 SWIPE_G 且 400ms 內回到參考±0.15g → 一次「甩」
* 甩方向由峰值符號決定;連搖計數滑動視窗決定「黑燈」巨集
* 回傳:true = 本迴圈觸發了手勢
* ------------------------------------------------------------ */
bool detectGesture() {
float x, y, z; readAccelG(x, y, z);
float dx = x - refAx, dy = y - refAy; // 相對參考手姿的水平偏移
float mag = sqrtf(dx*dx + dy*dy) + fabsf(z - refAz) * 0.4f; // z 加權低:跳動不是甩
static bool inSwipe = false;
static float peakDx = 0;
static unsigned long peakAt = 0;
unsigned long now = millis();
if (now < cooldownUntil) return false;
if (!inSwipe && fabsf(dx) > SWIPE_G) { // 進入甩動:記峰值方向
inSwipe = true; peakDx = dx; peakAt = now;
} else if (inSwipe) {
if (fabsf(dx) > fabsf(peakDx)) peakDx = dx;
if (mag < 0.15f && now - peakAt < 400) { // 回穩且在單擊時窗內 → 判定一次甩
inSwipe = false;
cooldownUntil = now + GESTURE_COOLDOWN_MS; // 冷卻先點火,再決定動作
if (fabsf(peakDx) < SHAKE_G) { // 一般力度 → 翻頁(方向=峰值符號)
sendKey(peakDx > 0 ? KEY_RIGHT_ARROW : KEY_LEFT_ARROW);
} else { // 大力搖 → 記帳進滑動視窗
shakeTimes[shakeHead] = now; shakeHead = (shakeHead + 1) % 4;
if (shakeTimes[0] && shakeTimes[3] - shakeTimes[0] < SHAKE_WINDOW_MS) {
sendKey('B'); // 1.6 秒內三次重搖 → 黑燈切換
shakeTimes[0] = shakeTimes[1] = shakeTimes[2] = shakeTimes[3] = 0;
}
}
return true;
}
if (now - peakAt > 800) inSwipe = false; // 回穩失敗(一直甩):放棄這次
}
return false;
}
/* ------------------------------------------------------------
* batteryGuard —— 電壓偵測:低電開機紅燈閃三下、運行中每 5 分報一次
* 為什麼用開機即報:HID 連線中斷時,低電+重列會變成
* 「簡報中途失靈」複合故障,提前一小時知道差很多
* ------------------------------------------------------------ */
bool batteryGuard() {
float v = analogReadMilliVolts(PIN_VBAT) / 1000.0f * VBAT_DIV;
static unsigned long lastReport = 0;
if (v < 3.45f && millis() - lastReport > 300000) {
lastReport = millis();
for (int i = 0; i < 3; i++) { // 快閃三下:不用聽聲音也知道該充電
digitalWrite(PIN_LED, HIGH); delay(120);
digitalWrite(PIN_LED, LOW); delay(120);
}
Serial.printf("[BAT] %.2fV 偏低,請充電\n", v);
}
return v > 3.20f; // 3.2V 以下強迫進睡,保護電芯
}
/* ------------------------------------------------------------
* setup —— USB 先於一切:電腦看到的是「插上一支鍵盤」
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
pinMode(PIN_LED, OUTPUT);
pinMode(PIN_BTN_A, INPUT_PULLUP); // 用內部上拉,外部不用補電阻
pinMode(PIN_BTN_B, INPUT_PULLUP);
analogSetPinAttenuation(PIN_VBAT, ADC_6db); // 6dB 衰減線性區到 3.3V,分壓後剛好蓋住 4.2V
Keyboard.begin();
USB.begin(); // 枚舉開始:電腦側 1 秒內出現 HID keyboard
if (!mpuInit()) { // IMU 掛了不能裝死:按鍵仍是完整簡報筆
Serial.println("[!] MPU6050 未回應——僅按鍵模式(檢查 I2C 兩線)");
} else {
calibrateRest();
delay(3000); // 手離開筆的 3 秒保護窗
}
Serial.println(" Gesture Presenter ready(甩動=翻頁,連搖三下=黑燈,長按B=F5)");
}
/* ------------------------------------------------------------
* loop —— 50Hz 輪詢:按鍵優先、手勢其次、靜置進睡
* ------------------------------------------------------------ */
unsigned long lastMotionAt = 0;
void loop() {
if (!batteryGuard()) { // 電壓保護:深度睡眠,按鍵喚醒再戰
esp_sleep_enable_ext0_wakeup((gpio_num_t)PIN_BTN_A, LOW);
esp_deep_sleep_start();
}
if (handleButtons()) { lastMotionAt = millis(); return; }
static bool imuOk = true;
if (imuOk && detectGesture()) lastMotionAt = millis();
if (millis() - lastMotionAt > IDLE_SLEEP_S * 1000UL) { // 靜置 90s:睡到按鍵喚醒
esp_sleep_enable_ext0_wakeup((gpio_num_t)PIN_BTN_B, LOW);
esp_deep_sleep_start(); // 深睡喚醒=重新開機=自動重校準,順利的副作用
}
delay(20);
}
⚙️ 使用流程(開會前 60 秒)
- 筆放桌面(或自然握持),滑開電源開關 → LED 亮一下=開始校準,別動它(2 秒)
- 筆尾縮回線拉出,插進筆電 USB-A → 系統自帶驅動,1 秒出現 HID keyboard
- PPT/Keynote 開始簡報;長按 B 鍵 2 秒=F5 開始、連搖三下=黑燈(B)、再搖三下=回復
- 換到陌生的投影環境時,改用實體鍵翻頁(手勢留給練過的場合),這就是「按鍵優先」的設計哲學
🏗️ 組裝步驟
- 列印零件:筆身上/下殼、按鍵帽 ×2、底座、縮回線壓蓋
- 縮回線預處理:轉接線根部預留 8cm,線頭剝線鍬鋰;根部纏一層熱縮再進壓蓋(斷裂點就在這裡,雙保險)
- 焊開關與充電板:滑開關串在電池正極與 VBAT 之間;TP4056 板 B± 先量極性再上錫
- 主板與按鍵:按鍵焊在副板(或直接上殼卡位),30AWG 矽膠線走線,拍照記錄方向
- MPU6050 鎖前端:模組長邊平行筆身,箭頭朝筆尾(方向裝反=前後頁對調,韌體裡就不用改,改接線)
- 上電校準與試飛:開序列埠 115200,看 [CAL] 輸出;接上電腦開 PPT 試翻 10 頁,手勢成功率 <9/10 就把 SWIPE_G 下修 0.05 重試
✅ 驗收測試
| 測試 | 步驟 | 合格標準 |
| USB HID 枚舉 | 縮回線插電腦,開系統裝置管理員 | 出現 HID Keyboard Device,1 秒內 |
| 按鍵零延遲 | 連按 10 次下一頁 | 10/10 頁頁對、無連發 |
| 手勢成功率 | 站姿正常甩手 10 次 | ≥9 次翻頁、0 次反向 |
| 走路不誤觸 | 握在筆上走動 30 秒 | 零翻頁(走路垂直加速度 <0.2g,遠低於閾值) |
| 斜持穩定 | 斜 45° 握持靜置 10 秒 | 零誤觸(已對參考手姿做差分) |
| 黑燈巨集 | 1.6 秒內連搖三下 | PPT 黑屏/回復各一次,不誤翻頁 |
| 睡眠喚醒 | 靜置 90 秒後按鍵 | ≤2 秒恢复可操作;待機電流表測 <100µA(含分壓漏電) |
🆘 問題排除
| 症狀 | 最可能的原因 | 處方 |
| 插電腦沒反應 | S2 停在 ROM 下載模式/USB 線是純充電線 | 按 RST 退出燒錄模式;換已知可資料線;SuperMini 部分板需按住 BOOT 再按 RST 後立即重插 |
| 電腦出現鍵盤但按鍵無效 | USB.begin() 前先執行了 Keyboard 操作(枚舉未完成) | 本程式已把 Keyboard.begin()→USB.begin() 放 setup 最前;自行改版請維持順序 |
| WHO_AM_I 讀不到 | SDA/SCL 接反、模組 VCC 接 5V 超 S2 邏輯 | I2C 兩線對調試一次;本專案全程 3V3 |
| 翻頁方向相反 | IMU 箭頭裝反(x 軸符號反) | 把 sendKey 兩個方向參數對調,或重裝模組——韌體一行的事 |
| 手勢偶發連翻兩頁 | 冷卻窗短於你的手抖頻率 | SWIPE_G 上調 0.05 或 COOLDOWN 加到 1200ms |
| 放進口袋自己狂翻頁 | 未關電源開關(深睡要 90 秒才進入) | 養成「收進口袋前推一下 B 鍵倒數」習慣,或直接關開關——這就是實體開關的價值 |
| 充電時無法使用 | 充電電流與 HID 供電共用路徑電壓跌落 | 縮回線拔除電腦改充電;或充飽再上場(40 分鐘) |
🎯 進階延伸
- 雙裝置熱備:USBHIDMouse 一起註冊,長按雙鍵切簡報/滑鼠模式——台下順手幫你關通知跳窗
- 振動馬達回饋:LRA 馬達+DRV2605,手勢成功時指尖「嗒」一聲,比 LED 更台上友好
- Wi-Fi 遙控第二機:S2 本體就是 Wi-Fi 晶片,加寫 20 行 BLE?不——加寫 WebSocket 伺服器,手機瀏覽器變第三個按鍵
- 指紋開機:R503 模組藏在握位,只有你的拇指按得亮——簡報筆變成你的生物識別鑰匙
📁 專案檔案結構
gesture-presenter/
├── firmware/
│ └── gesture_presenter.ino # 上方完整韌體
├── stl/
│ ├── pen-top.stl # 筆身上殼(按鍵窗)
│ ├── pen-bottom.stl # 筆身下殼(電池艙)
│ ├── keycap-x2.stl # 按鍵帽
│ ├── dock.stl # 充電底座
│ └── cable-clamp.stl # 縮回線壓蓋(PETG)
├── f3d/
│ └── gesture-presenter.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件
🎯 結語
阿立後來帶著這支筆上季報:站在台上,左手自然垂下往左一揮,投影片翻頁,台下安靜,鏡頭沒有一個人注意到他。只有他自己知道筆身裡那顆 28 元的陀螺儀剛剛做了兩次積分。「免驅動」三個字聽起來無聊,但它是簡報開始前那 30 秒裡,你唯一不需要祈禱的事。下一期我們讓煙霧自己報警——一個會看煙速風扇的智慧烙鐵抽風機,烙鐵舉起三秒後,風扇已經在等你了。