📋 專案概述

朋友做網拍倉儲,三個月前找我求救:每月盤點日,她一手拿手機開掃碼 App、一手抄 Excel,兩百多個 SKU 掃完拇指抽筋還常錯行。更慘的一次,App 當機沒存檔,兩小時作廢,隔天重盤。盤點這種工作,工具每錯一步,人就賠一小時。

市售方案我們查過一圈,各有各的坑:

  1. 商用 PDA 盤點機:一台 3,000~8,000 元,跑著十年前的 Android,掃描很強、其他一塌糊塗——她只是要把條碼進 Excel,不是要口袋裡裝支手機。
  2. 藍牙鍵盤模擬掃描槍(約 900 元):號稱免驱,實際上換筆電就失聯、中文輸入法開著就吞字、電量焦慮,公司那台已經在抽屜裡吃灰。
  3. 手機 App 掃碼:免費,但掃完的資料困在 App 裡,貼进 Excel 還要手動分行——她現在的痛就是這個。

答案其實就藏在那台吃灰掃描槍的「工作方式」裡:讓電腦以為它是一把鍵盤。鍵盤是全世界相容性最好的裝置——把條碼「打字」進游標所在的儲存格,按 Enter 自動跳下一列,Excel、Google Sheet、瀏覽器後台通吃。這用 ESP32-S3 的原生 USB 就能做到,零件加列印不到 560 元:

成本效益很直白:商用 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 OLED1~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 DevKitC-1 原生USB USB → 電腦(HID+供電) GPIO18 (RX2) GPIO17 (TX2) 5V GND GPIO8/9 (I2C) GPIO12 蜂鳴 GPIO13 按鍵 GM63 掃描頭 TXD RXD VCC(5V) GND SSD1306 OLED CNT 128 SKU 42 蜂鳴器 按鍵 紫線 GPIO18(RX2) ← TXD 橘線 GPIO17(TX2) → RXD(配置用) 紅線 5V → VCC 黑線 GND → GND(必接) 藍 SDA→8 黃 SCL→9 粉 12→+ 綠 13→信號(另一端 GND)
▲ 掃描頭 UART 必須交叉:掃描頭 TXD → 板子 RX2。RXD 那條只在掃設定碼時用,平時可拔。

詳細接線

ESP32-S3 腳位連接說明
GPIO18(UART2 RX)掃描頭 TXD條碼資料流;9600 8N1
GPIO17(UART2 TX)掃描頭 RXD只在用掃描頭掃「設定碼」改韌體時需要,日常可斷
GPIO8 / GPIO9OLED SDA / SCLS3 常用 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 鍵盤 + 供電)

⚙️ 掃描頭設定(必做,否則一個字都不出)

  1. 掃描頭出廠預設多半是「USB 鍵盤模式」或「鍵盤仿真」,它會自己裝成鍵盤——那樣這顆 ESP32 就沒事可做。翻出隨附設定手冊。
  2. 依次掃三張設定碼:① UART/串口模式 → ② 鮑率 9600 → ③ 新增字尾 CR(Enter)。掃完會「嗶—嗶—」確認。
  3. 掃一張一維碼 + 一張 QR 碼測試:序列監控(本韌體 UART0 走 USB CDC)應看到明文條碼+換行。
  4. 若手冊遺失:淘寶賣家頁面幾乎都附 PDF 設定手冊下載,關鍵字「GM63 設定手冊」。
💡 韌體上線後,掃描頭的 RXD 那條線就功成身退。日后想改設定再接回去即可。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
槍柄下殼(握把+電池位預留)1PLA深色~2.5 hr
槍頭上殼(掃描頭 45° 視窗)1PLA深色~2 hr
OLED 視窗蓋(霧面)1PLA(3 層實心)黑~20 min
按鍵帽1PLA橘(好找)~10 min
磁吸站立底座1PLA任何~1 hr

列印設定

設計要點

  1. 掃描窗 45° 傾角:符合手持掃描的人體工學,貼著貨架也能掃
  2. 視窗防刮唇:視窗四周凸 2mm,桌面直接推放時磨的是唇不是光學窗
  3. 扳機位=按鍵:微動從槍柄上方鎖入,拇指自然落點
  4. 底座磁吸:兩顆 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 組裝說明書
  1. 列印零件:5 個零件,槍頭上殼建議順著視窗方向改 0.12mm 層厚
  2. 掃描頭入槍頭:四顆 M2 螺絲由內側鎖入(螺孔在殼內預留凸柱),鏡頭朝視窗、別貼太近(留 3mm 氣隙對焦)
  3. 接線:UART 交叉(掃描頭 TXD→GPIO18)、5V/GND 共地;先全部燒錄測試過一次再合殼,合殼後就無法焊
  4. 掃描頭設定:接回 RXD,掃「UART 模式+9600+CR 後綴」三張設定碼(見上方章節)
  5. 燒錄與驗收:USB 插電腦 → 開記事本 → 掃一維碼與 QR:應看到「條碼+自動換行」
  6. Excel 實戰設定:關閉選區「自動換行」;游標放 A1 開始掃,每掃一筆自動跳 A2、A3
  7. 合殼上底座:埋磁鐵、鎖底蓋,站上底座收工

🔧 問題排除

問題原因解決方法
掃了完全沒反應掃描頭還在出廠 USB 鍵盤模式掃設定碼切 UART+9600+CR 後綴(最常見的一關)
有條碼但游標不跳下一列沒加 CR 字尾補掃「CR 後綴」設定碼
電腦出現未知裝置燒到非原生 USB 版型/外接了 GPIO19/20確認板子是原生 USB 口版本;拔掉 GPIO19/20 上的東西
符號錯亂、少字電腦開著中文輸入法,HID 字元被輸入法攔截切英文輸入法再掃(開機 log 會提醒你)
同一件被計兩次掃描頭對同碼快速連發這是 DEDUP_MS=2000 保護;若你真的兩件同碼連掃,把視窗調小或改連掃模式
插舊式 USB 集線器會重啟掃描頭峰值 280mA 拉垮電壓直插電腦 USB 孔,或改用有電源集線器
OLED 完全不亮地址 0x3D 或 SDA/SCL 接反韌體已試雙地址;查線序

🎯 進階延伸

📦 檔案清單

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                   # 說明文件
← 上一篇:ESP32-C3 廚房瓦斯洩漏哨兵 📚 系列索引 下一篇:ESP32-C6 恆溫智慧隔熱杯墊 →