📋 專案概述
朋友做網拍倉儲,三個月前找我求救:每月盤點日,她一手拿手機開掃碼 App、一手抄 Excel,兩百多個 SKU 掃完拇指抽筋還常錯行。更慘的一次,App 當機沒存檔,兩小時作廢,隔天重盤。盤點這種工作,工具每錯一步,人就賠一小時。
市售方案我們查過一圈,各有各的坑:
- 商用 PDA 盤點機:一台 3,000~8,000 元,跑著十年前的 Android,掃描很強、其他一塌糊塗——她只是要把條碼進 Excel,不是要口袋裡裝支手機。
- 藍牙鍵盤模擬掃描槍(約 900 元):號稱免驱,實際上換筆電就失聯、中文輸入法開著就吞字、電量焦慮,公司那台已經在抽屜裡吃灰。
- 手機 App 掃碼:免費,但掃完的資料困在 App 裡,貼进 Excel 還要手動分行——她現在的痛就是這個。
答案其實就藏在那台吃灰掃描槍的「工作方式」裡:讓電腦以為它是一把鍵盤。鍵盤是全世界相容性最好的裝置——把條碼「打字」進游標所在的儲存格,按 Enter 自動跳下一列,Excel、Google Sheet、瀏覽器後台通吃。這用 ESP32-S3 的原生 USB 就能做到,零件加列印不到 560 元:
- 插上任何 Windows / Mac / Linux 的 USB 孔,免驅動列舉為 HID 鍵盤
- 掃描 → 條碼直接進儲存格 → 自動 Enter 跳下一列
- 防重掃:同一碼 2 秒內再掃 = 低雙響忽略(盤點最大的誤差來源就是重複計數)
- 計數/連掃雙模式:OLED 即時顯示總筆數與唯一品項數,按鍵切換
- 離線佇列:USB 瞬間斷線時先存進 32 筆佇列,回連自動補打,一筆不丟
- 蜂鳴器三段回饋:成功短促一聲、重複低雙響、錯誤長響
成本效益很直白:商用 PDA 3,000 元起、藍牙槍 900 元還附贈相容性盲盒;這把槍 NT$555,壞了也只是把 USB 線拔了的事。她現在月盤兩次、每次從 2 小時噴到 40 分鐘——半年省下的工時夠買十把。
為什麼選 ESP32-S3?
| 需求 | S3 的優勢 |
|---|---|
| USB HID 裝置 | 全系列唯一平價且有原生 USB OTG 的型號(C2/C3/C5/C6/H2 的 USB 是 Serial-JTAG 除錯埠,做不了鍵盤裝置) |
| 雙序列埠 | UART0 給 USB 除錯、UART2 專供掃描頭,互不干擾 |
| 記憶體 | 內建 PSRAM 選項+484KB SRAM,離線佇列與唯一品項表綽綽有餘 |
| 日後擴充 | 要改 Wi-Fi 即時同步庫存、甚至加相機補光,同一塊板不用換 |
💡 S2 也有原生 USB,但缺 PSRAM、市面上的新板越來越難買;S3 目前是「要當 USB 裝置」的唯一省錢解。
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-S3-DevKitC-1(N8R2) | 1 | ~210 元 | 必須選原生 USB 口版本(USB 座直接焊在 S3 晶片那組腳) |
| 掃描頭 | GM63 類一維/二維 TTL 串口掃描頭 | 1 | ~180 元 | 需支援掃設定碼切換 UART 模式+CR 後綴;5V 供電 |
| 顯示 | SSD1306 0.96" I2C OLED | 1 | ~90 元 | 地址 0x3C(0x3D 亦自動嘗試) |
| 蜂鳴器 | 3.3V 有源蜂鳴器 | 1 | ~15 元 | GPIO 直推(模組內建三極體的可免) |
| 按鍵 | 6×6mm 微動開關 | 1 | ~5 元 | 槍柄拇指位置,對 GND |
| 線材/雜項 | 28AWG 硅胶線、磁吸腳墊、熱熔膠 | 1 | ~30 元 | 槍身內走線要軟線 |
| 列印 | PLA 約 40g | — | ~15 元 | 槍柄+站立底座 |
| 合計 | ~555 元 |
⚠️ 供電總帳:掃描頭峰值約 280mA+OLED 20mA+S3 峰值 ≈ 400mA,貼近 USB 2.0 的 500mA 上限。韌體已將 OLED 刷新限速;若接老舊 USB 集線器會重啟,請直插電腦。
🔌 接線圖
詳細接線
| ESP32-S3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO18(UART2 RX) | 掃描頭 TXD | 條碼資料流;9600 8N1 |
| GPIO17(UART2 TX) | 掃描頭 RXD | 只在用掃描頭掃「設定碼」改韌體時需要,日常可斷 |
| GPIO8 / GPIO9 | OLED SDA / SCL | S3 常用 I2C 預設腳,模組自帶上拉 |
| GPIO12 | 蜂鳴器 + | 3.3V 有源,高準位發聲 |
| GPIO13 | 按鍵 → GND | 內部上拉,按下為低 |
| 5V / GND | 掃描頭 VCC / GND | 掃描頭 5V 供電,與板子共地 |
⚠️ GPIO19/20 是 S3 原生 USB 的 D−/D+,全系列腳位表上最容易誤用的兩支——外接任何东西都會讓電腦認不到這把「鍵盤」。
ASCII 對照表
ESP32-S3 DevKitC-1 GM63 掃描頭 SSD1306
┌─────────────┐ ┌──────────┐ ┌─────────┐
│ RX2 GPIO18 ├──────→ TXD │ │
│ TX2 GPIO17 ┼──────→ RXD(選) │ │
│ 5V GPIO ├───┬──→ VCC │ │
│ GND GPIO ├───┼──→ GND │ SDA ←┤── GPIO8
│ │ │ └──────────────│─ SCL ←┤── GPIO9
│ GPIO12 ├──── 蜂鳴器(+) └──────────┘
│ GPIO13 ├──── 按鍵 ─ GND
└──────┬──────┘
USB ──── 電腦(HID 鍵盤 + 供電)
⚙️ 掃描頭設定(必做,否則一個字都不出)
- 掃描頭出廠預設多半是「USB 鍵盤模式」或「鍵盤仿真」,它會自己裝成鍵盤——那樣這顆 ESP32 就沒事可做。翻出隨附設定手冊。
- 依次掃三張設定碼:① UART/串口模式 → ② 鮑率 9600 → ③ 新增字尾 CR(Enter)。掃完會「嗶—嗶—」確認。
- 掃一張一維碼 + 一張 QR 碼測試:序列監控(本韌體 UART0 走 USB CDC)應看到明文條碼+換行。
- 若手冊遺失:淘寶賣家頁面幾乎都附 PDF 設定手冊下載,關鍵字「GM63 設定手冊」。
💡 韌體上線後,掃描頭的 RXD 那條線就功成身退。日后想改設定再接回去即可。
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 槍柄下殼(握把+電池位預留) | 1 | PLA | 深色 | ~2.5 hr |
| 槍頭上殼(掃描頭 45° 視窗) | 1 | PLA | 深色 | ~2 hr |
| OLED 視窗蓋(霧面) | 1 | PLA(3 層實心) | 黑 | ~20 min |
| 按鍵帽 | 1 | PLA | 橘(好找) | ~10 min |
| 磁吸站立底座 | 1 | PLA | 任何 | ~1 hr |
列印設定
- 填滿:≥25%——倉儲環境會摔,这是工具不是擺件
- 層厚:0.2mm;槍頭視窗周围 0.12mm(層紋會刮花掃描窗)
- 支撐:僅槍頸下方;視窗開孔一律在切片軟體開孔,不要印完用鑽的
設計要點
- 掃描窗 45° 傾角:符合手持掃描的人體工學,貼著貨架也能掃
- 視窗防刮唇:視窗四周凸 2mm,桌面直接推放時磨的是唇不是光學窗
- 扳機位=按鍵:微動從槍柄上方鎖入,拇指自然落點
- 底座磁吸:兩顆 6×2mm 鎳磁鐵埋進槍柄底與底座,站立/吸附收納兩用
💻 程式碼
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-S3 條碼盤點掃描槍 | USB-HID Barcode Scanner
* ------------------------------------------------------------
* 功能:GM63 類 TTL 掃描頭讀條碼 → 以 USB HID 鍵盤「打字」進
* 電腦游標處+Enter;防重掃、計數/連掃雙模式、
* 唯一品項統計、USB 斷線 32 筆佇列自動續傳
* 硬體:ESP32-S3 DevKitC-1(原生 USB)+ GM63 TTL 掃描頭(UART2)
* + SSD1306 OLED(I2C)+ 有源蜂鳴器 + 微動按鍵
* 供電:電腦 USB 孔 5V(總流 < 400mA,勿接老式 USB 集線器)
* 相依:Arduino core for ESP32 ≥ 2.0.14(USB / USBHIDKeyboard 內建)
* Adafruit SSD1306 + Adafruit GFX
* IDE:板選 "ESP32S3 Dev Module";USB CDC On Boot: Enabled
* (保留序列除錯,HID 由 USBHIDKeyboard 自行註冊)
* ============================================================ */
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <USB.h>
#include <USBHIDKeyboard.h>
/* ------------------------------------------------------------
* 1. 腳位與參數
* ------------------------------------------------------------ */
const int PIN_SCAN_RX = 18; // UART2 RX ← 掃描頭 TXD(交叉!)
const int PIN_SCAN_TX = 17; // UART2 TX → 掃描頭 RXD(僅配置時用到)
const int PIN_BUZZER = 12; // 有源蜂鳴器,高準位發聲
const int PIN_BUTTON = 13; // 微動按鍵,對 GND,內部上拉
const unsigned long DEDUP_MS = 2000; // 同碼重複保護窗口:倉庫翻面連掃最常造成「同件兩次」
const unsigned long PARTIAL_MS = 100; // 半截碼逾時:掃描頭偶爾吐一半就停,超时丢棄避免髒碼
const uint8_t UNIQUE_MAX = 48; // 唯一品項表容量:超出只計總數不計品項,宁可少報不可爆 RAM
const uint8_t QUEUE_MAX = 32; // 離線佇列深度:USB 瞬間斷線時的暫存额度
/* ------------------------------------------------------------
* 2. 全域物件與狀態
* ------------------------------------------------------------ */
USBHIDKeyboard Keyboard;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
String rxBuffer = ""; // 掃描頭字元累積區(\r 結尾成碼)
String lastCode = ""; // 上一筆成功處理的條碼(去重比對用)
unsigned long lastScanMs = 0; // 上一筆時間戳
String uniqueList[UNIQUE_MAX]; // 唯一品項線性表(條碼長 ≤ 32,線性搜尋足夠快)
uint8_t uniqueCount = 0;
uint16_t totalCount = 0;
bool tallyMode = true; // true=計數模式(盤點)/false=連掃模式(純輸入)
String qRing[QUEUE_MAX]; // 離線佇列(環形):USB 未 ready 時先存這裡
uint8_t qHead = 0, qTail = 0;
/* 按鍵去抖與手勢 */
bool btnLast = HIGH;
unsigned long btnChangeMs = 0;
unsigned long btnPressMs = 0;
bool btnHandledLong = false;
/* ------------------------------------------------------------
* beep —— 蜂鳴器音型(阻塞式,最長 300ms 可接受:
* 掃描場景一次蜂鳴期間不會漏字,UART2 有 256B FIFO 擋著)
* 參數:on/off = 單聲長短(ms),times = 連響次數
* ------------------------------------------------------------ */
void beep(uint16_t onMs, uint16_t offMs, uint8_t times) {
for (uint8_t i = 0; i < times; i++) {
digitalWrite(PIN_BUZZER, HIGH);
delay(onMs);
digitalWrite(PIN_BUZZER, LOW);
if (i < times - 1) delay(offMs);
}
}
/* ------------------------------------------------------------
* rememberUnique —— 登記唯一品項
* 回傳:是否為新品項(true = 第一次見到這碼)
* 做法:線性搜尋;表滿時回傳 true 但不儲存(總數仍正確)
* ------------------------------------------------------------ */
bool rememberUnique(const String& code) {
for (uint8_t i = 0; i < uniqueCount; i++)
if (uniqueList[i] == code) return false; // 見過
if (uniqueCount < UNIQUE_MAX) uniqueList[uniqueCount++] = code;
return true;
}
/* ------------------------------------------------------------
* queuePush / queueFlush —— 離線佇列
* 為什麼要佇列:USB 線被踢到、電腦睡醒瞬間,掃描不能白掃;
* HID 沒有「回傳失敗」可重試,只能自己在端上緩存重播
* ------------------------------------------------------------ */
void queuePush(const String& code) {
uint8_t next = (qHead + 1) % QUEUE_MAX;
if (next == qTail) { // 佇列滿:丟最舊,保最新
qTail = (qTail + 1) % QUEUE_MAX;
Serial.println(F("[Q] 佇列已滿,丟棄最舊一筆"));
}
qRing[qHead] = code;
qHead = next;
}
void queueFlush() {
while (qTail != qHead && USB.ready()) { // 逐筆補打,中途再斷線就停下等下次
Keyboard.print(qRing[qTail].c_str());
Keyboard.println(); // Enter:Excel 預設跳下一列
qTail = (qTail + 1) % QUEUE_MAX;
delay(15); // 給 HID 報告描述器留發送間隔,連打太快部分主機丟字
}
}
/* ------------------------------------------------------------
* typeCode —— 一條編碼的最終出口(HID 直打或進佇列)
* 參數:code = 已完成去重判斷的條碼
* ------------------------------------------------------------ */
void typeCode(const String& code) {
if (USB.ready()) {
queueFlush(); // 先補完舊帳再打新的,順序不乱
Keyboard.print(code.c_str());
Keyboard.println();
} else {
queuePush(code); // USB 不在線:存佇列,開機/回連後自動補
}
}
/* ------------------------------------------------------------
* handleScanned —— 一條完整條碼的業務判斷
* 流程:去重 → 計數/品項登記 → 輸出 → UI 回饋
* ------------------------------------------------------------ */
void handleScanned(const String& code) {
unsigned long now = millis();
if (code == lastCode && now - lastScanMs < DEDUP_MS) {
beep(60, 90, 2); // 低雙響 = 這件剛掃過,重複保護生效
Serial.println("[DEDUP] " + code);
return;
}
lastCode = code;
lastScanMs = now;
totalCount++;
bool isNew = rememberUnique(code);
typeCode(code); // 兩種模式都把條碼送進電腦(計數模式只是多了計數語意)
Serial.printf("HIT total=%u unique=%u %s %s\n",
totalCount, uniqueCount, isNew ? "(NEW)" : "", code.c_str());
beep(50, 0, 1); // 成功:短促一聲
drawStatus();
}
/* ------------------------------------------------------------
* scanRx —— UART2 字元狀態機:累積條碼、\r 結尾、逾時棄半碼
* 掃描頭可能中途停發(對焦失敗),半截碼混進表格比漏掃更毒
* ------------------------------------------------------------ */
void scanRx() {
while (Serial2.available()) {
char c = (char)Serial2.read();
if (c == '\r' || c == '\n') {
rxBuffer.trim();
if (rxBuffer.length() >= 3) handleScanned(rxBuffer); // <3 字元視為雜訊
rxBuffer = "";
} else if (isPrintable(c) && rxBuffer.length() < 64) { // 上限防禦:韌體錯亂時的長字串不撐爆堆
rxBuffer += c;
}
}
}
/* ------------------------------------------------------------
* pollButton —— 按鍵手勢:單擊切模式/長按 1.5s 歸零
* 50ms 消抖;長按觸發後標記,放開時不再補發單擊
* ------------------------------------------------------------ */
void pollButton() {
bool raw = digitalRead(PIN_BUTTON);
unsigned long now = millis();
if (raw != btnLast) {
if (now - btnChangeMs > 50) { // 消抖窗口
btnChangeMs = now;
btnLast = raw;
if (raw == LOW) { btnPressMs = now; btnHandledLong = false; }
else if (!btnHandledLong && now - btnPressMs < 1200) {
tallyMode = !tallyMode; // 單擊:切計數/連掃
beep(tallyMode ? 80 : 60, 60, tallyMode ? 1 : 2);
drawStatus();
}
}
}
if (raw == LOW && !btnHandledLong && now - btnPressMs > 1500) {
btnHandledLong = true; // 長按:盤點歸零
totalCount = 0; uniqueCount = 0; lastCode = "";
qHead = qTail = 0;
beep(150, 60, 3);
drawStatus();
}
}
/* ------------------------------------------------------------
* drawStatus —— OLED 面板:模式/計數/最後一條碼
* 全圖重繪,I2C 約 1KB 傳輸,只在事件時呼叫(不按幀刷新省電流)
* ------------------------------------------------------------ */
void drawStatus() {
oled.clearDisplay();
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.setCursor(0, 0);
oled.print(tallyMode ? "MODE:TALLY" : "MODE:SWEEP");
oled.println(USB.ready() ? " USB:OK" : " USB:OFF");
oled.printf("CNT:%-5u SKU:%-4u\n", totalCount, uniqueCount);
String last = lastCode;
if (last.length() > 21) last = last.substring(0, 21) + "..";
oled.println(last);
oled.printf("Q:%u %s\n", (uint8_t)((qHead - qTail + QUEUE_MAX) % QUEUE_MAX),
qHead != qTail ? "RETRY-LATER" : "");
oled.display();
}
/* ------------------------------------------------------------
* setup —— 開機初始化:蜂鳴/按鍵 → OLED → UART2 → HID 註冊
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
delay(800); // 等 USB-CDC 列舉完成,否則開機 log 不見
Serial.println(F("\n=== ESP32-S3 Barcode Scanner ==="));
pinMode(PIN_BUZZER, OUTPUT); digitalWrite(PIN_BUZZER, LOW);
pinMode(PIN_BUTTON, INPUT_PULLUP);
Wire.begin(8, 9); // SDA=8, SCL=9
if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C))
oled.begin(SSD1306_SWITCHCAPVCC, 0x3D); // 部分模組出廠 0x3D
oled.clearDisplay();
Serial2.begin(9600, SERIAL_8N1, PIN_SCAN_RX, PIN_SCAN_TX);
Keyboard.begin(); // 先註冊 HID 裝置描述器
USB.begin(); // 再列舉到主機(順序錯了主機看不到鍵盤)
beep(80, 0, 1);
drawStatus();
Serial.println(F("就緒。掃描前請把電腦輸入法切到英文,否則中文字組會吞掉符號。"));
}
/* ------------------------------------------------------------
* loop —— 掃描接收 + 按鍵 + 半碼逾時清理
* ------------------------------------------------------------ */
void loop() {
scanRx();
pollButton();
if (rxBuffer.length() > 0 && millis() - lastScanMs > DEDUP_MS + PARTIAL_MS * 5) {
/* 半碼清理:收到字元卻遲遲沒有 \r(掃描頭對焦失敗),
留太久會在下一條碼開頭混入髒字串 → 直接丟棄 */
rxBuffer = "";
}
delay(5); // 讓出 CPU;UART2 FIFO 64→256B,5ms 輪詢不會漏
}
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:5 個零件,槍頭上殼建議順著視窗方向改 0.12mm 層厚
- 掃描頭入槍頭:四顆 M2 螺絲由內側鎖入(螺孔在殼內預留凸柱),鏡頭朝視窗、別貼太近(留 3mm 氣隙對焦)
- 接線:UART 交叉(掃描頭 TXD→GPIO18)、5V/GND 共地;先全部燒錄測試過一次再合殼,合殼後就無法焊
- 掃描頭設定:接回 RXD,掃「UART 模式+9600+CR 後綴」三張設定碼(見上方章節)
- 燒錄與驗收:USB 插電腦 → 開記事本 → 掃一維碼與 QR:應看到「條碼+自動換行」
- Excel 實戰設定:關閉選區「自動換行」;游標放 A1 開始掃,每掃一筆自動跳 A2、A3
- 合殼上底座:埋磁鐵、鎖底蓋,站上底座收工
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 掃了完全沒反應 | 掃描頭還在出廠 USB 鍵盤模式 | 掃設定碼切 UART+9600+CR 後綴(最常見的一關) |
| 有條碼但游標不跳下一列 | 沒加 CR 字尾 | 補掃「CR 後綴」設定碼 |
| 電腦出現未知裝置 | 燒到非原生 USB 版型/外接了 GPIO19/20 | 確認板子是原生 USB 口版本;拔掉 GPIO19/20 上的東西 |
| 符號錯亂、少字 | 電腦開著中文輸入法,HID 字元被輸入法攔截 | 切英文輸入法再掃(開機 log 會提醒你) |
| 同一件被計兩次 | 掃描頭對同碼快速連發 | 這是 DEDUP_MS=2000 保護;若你真的兩件同碼連掃,把視窗調小或改連掃模式 |
| 插舊式 USB 集線器會重啟 | 掃描頭峰值 280mA 拉垮電壓 | 直插電腦 USB 孔,或改用有電源集線器 |
| OLED 完全不亮 | 地址 0x3D 或 SDA/SCL 接反 | 韌體已試雙地址;查線序 |
🎯 進階延伸
- 校驗拒收:Code-128 checksum 或品編前綴規則檢查,不符規則長響+紅燈,當場擋下錯件
- SD 卡脫機盤點:加 microSD 模組,脫機存 CSV 回桌一鍵吐給 Excel——整層倉不用拖筆電
- NAS 即時同步:掃描的同時 Wi-Fi POST 到 NAS 庫存服務,倉管後台即時跳數字(S3 的 Wi-Fi 本來就在板上)
- 電池無線版:18650+TP4056+升壓進 5V 腳,間歇掃描下 S3 light-sleep 平均 < 20mA,撐一整個盤點班
📦 檔案清單
s3-barcode-scanner/
├── src/
│ └── barcode-scanner.ino # ESP32 程式碼(本頁完整程式)
├── stl/
│ ├── grip-lower.stl # 槍柄下殼
│ ├── head-upper.stl # 槍頭上殼(45° 視窗)
│ ├── oled-cover.stl # OLED 視窗蓋
│ ├── button-cap.stl # 按鍵帽
│ └── dock-base.stl # 磁吸站立底座
├── f3d/
│ └── scanner-design.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件