# Bluetooth Label Printer

> 瀏覽器用 Web Bluetooth 直連藍牙熱感標籤機列印的實戰手冊（主參考機種 XP-P3301B / TSPL）。 只要任務牽涉到：Web Bluetooth 送印、熱感/標籤/收據印表機、TSPL 或 ESC/POS 指令、對印表機 BLE `write()`、列印時跳「GATT error unknown」或 Android status 133、連印多張卡在第 2 張、 列印很慢/卡住/印一張就當、印表機就緒/缺紙/開蓋偵測（`~HS` 狀態）、中文(CJK)標籤印出來變亂碼、 或換頁後藍牙就斷線——就要用本 skill，即使使用者只說「標籤機不會印」「列印卡住」也一樣。 收錄：一次列印一次 write 原則、NR+ACK 流控、逐張抓圖、`~HS` 狀態解析（認型號 fail-open）、 內嵌 CJK 點陣列印、換頁自動重連。這些都是實機多次踩雷後定案的血淚，別再重推一次。

- Skill: `alanpai/bluetooth-label-printer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add alanpai/bluetooth-label-printer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/alanpai/bluetooth-label-printer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: AlanPai (https://skillmd.com/u/alanpai)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/alanpai/bluetooth-label-printer

---


# 藍牙熱感標籤機列印（Web Bluetooth）

在**瀏覽器**用 Web Bluetooth 直接連藍牙熱感標籤機列印，看似「連上→write bytes→印出來」，實際上有幾個
**只有實機才踩得出來的雷**。這份 skill 就是把那些雷和唯一可行的配方固化下來，讓你不用再從頭 debug 一遍。

**主參考機種：XP-P3301B（吃 TSPL，走 BLE notify 回狀態）。** 下面標「(P3301B 實測)」的是這台實測值；
其餘（一次一 write、NR+ACK、逐張抓圖、換頁重連、CJK 點陣）是**跨機種通用**的原則。換別台機種時，
通用原則照用，實測欄位要用「換機種怎麼移植」那節重新推導。

參考實作：`youfu-picklist-locator` 專案 `app.py` 的 `_BT_PRINT_JS` 模組（`BtPrinter.*`）。

---

## 鐵則 1：一次列印，只能呼叫「一次」`write()`（最大的雷）

**一張標籤開一次 `write()`、連續印多張 → 第 2 次寫入就丟 `GATT error unknown`（Android status 133）。**
這台印表機（+ 這支手機）不吃「分多次、各自開頭的寫入」；第一張會成功，第二張必掛。

**唯一解法：把 N 張標籤的資料拼成「一份」連續的 bytes（同一條 TSPL 串流），只呼叫一次 `write()`。**
印表機在同一串流裡收到 N 張、照樣一張張印出來。

```
// ✗ 會掛：逐張各自 write
for (const label of labels) await char.writeValue(label.bytes);   // 第 2 張 → GATT 133

// ✓ 可行：合併成一份，只 write 一次
const merged = concatBytes(labels.map(l => l.bytes));
await writeInChunks(char, merged);   // 見鐵則 2 的分塊送法
```

為什麼：BLE 的 GATT 對這台的韌體來說，兩次獨立的 write transaction 之間狀態沒清乾淨就會拒絕第二次。
把它當成「一次連線只給一次連續灌資料的機會」最保險。

---

## 鐵則 2：`write()` 內部——NR 快灌 + 每 N 塊一次 ACK（流控）

一份資料通常大於單次 BLE 封包上限，要**分塊（chunk）**送。兩種寫法：
- **NR** = `writeValueWithoutResponse`（無回應）→ 快，但不等印表機確認。
- **ACK** = `writeValueWithResponse`（有回應）→ 慢，但每塊都等印表機收妥（等於流控）。

**純 NR、只在結尾 ACK 一次 → 印表機緩衝區被灌爆、印一張就卡死。** 純逐塊 ACK → 最穩但慢（送印約 5s）。

**可行配方：NR 快灌 + 每 `ACK_EVERY` 塊插一次 ACK 做印表機端流控。** (P3301B 實測 `ACK_EVERY=6`：
送印從 ~5s 降到 ~1s，且不爆緩衝。)

```
const ACK_EVERY = 6;                 // 速度 vs 穩定 的旋鈕：印壞/卡就調小（最穩=1，即全 ACK）
const CHUNK = 200;                   // 單塊 bytes（依 MTU 調；另一個旋鈕）
for (let i = 0, n = 0; i < data.length; i += CHUNK, n++) {
  const slice = data.subarray(i, i + CHUNK);
  if (n % ACK_EVERY === 0) await char.writeValueWithResponse(slice);   // 週期性 ACK＝流控
  else                     await char.writeValueWithoutResponse(slice); // 其餘 NR 快灌
}
```

`ACK_EVERY` 和 `CHUNK` 就是「速度 vs 穩定」的兩顆旋鈕。列印出現亂碼/卡住，先把 `ACK_EVERY` 調小
（極限 = 1 = 全程 ACK，最穩最慢）。

---

## 鐵則 3：抓圖/組資料要「逐張循序」，不要 `Promise.all` 並發

若標籤內容是跟後端要（例如伺服器把每張標籤算成點陣圖回傳），**不要用 `Promise.all` 並發抓**。
(實測：後端同一算圖流程被並發呼叫會互卡，前端就一直停在「取圖中」。)

**逐張 `await` 抓完再抓下一張**，全部到齊後再依鐵則 1 合併成一份、鐵則 2 送出。慢一點點，但不會卡死。

```
const parts = [];
for (const tw of items) parts.push(await fetchLabelBytes(tw));   // 循序，不要 Promise.all
await writeInChunks(char, concatBytes(parts));
```

---

## 就緒偵測：缺紙 / 開蓋（TSPL `~HS`）

**送印前先確認印表機就緒**（有紙、蓋好），否則使用者以為印了、其實卡住。做法是查狀態。

### 查詢與回應 (P3301B 實測)
- **指令**：TSPL `~HS` = bytes `7E 48 53 0D 0A`（`~HS\r\n`）。回應**走 BLE notify**（要先訂閱 notify 特徵）。
- **這台只吃 TSPL**：ESC/POS `DLE EOT`（`10 04 01` / `10 04 04`）**不回應**，別用。
- **回應格式**：`2<CR>` + 3 段 `<STX>…<ETX><CR><LF>`，段內以逗號分隔欄位。

### 就緒欄位真值表 (P3301B 實測)
| 狀態 | 第1段第2欄 | 第2段第3欄 |
|---|---|---|
| 正常（裝紙、蓋好） | `0` | `0` |
| 缺紙（蓋好） | `1` | `0` |
| 開上蓋 / 印頭抬起 | `1` | `1` |

- **第1段第2欄** = 總不就緒旗標（`0`=就緒、`1`=缺紙或開蓋）。
- **第2段第3欄** = 開蓋旗標（`1`=開蓋）。
- 第2段第8欄 = 里程計數器（印過會跳動、與就緒無關，**忽略**）。

### 判定邏輯
```
第1段第2欄 == 0            → 就緒
第1段第2欄 == 1 且 第2段第3欄 == 0 → 缺紙
                第2段第3欄 == 1 → 開蓋
```

### 認型號 + fail-open（**非常重要**）
**上面的欄位對應只保證 XP-P3301B 適用**，別台機種欄位（甚至協定）可能完全不同。所以：
- **用裝置名認型號**（`/P3301/i`）**才套用**這套解析；
- **其他機種、沒回 notify、解析不出來 → 一律 fail-open（放行照印，絕不因為「查不到狀態」而擋死列印）。**

寧可漏擋（頂多印到缺紙），也不要因為狀態偵測失敗而讓所有列印都印不出來。

### 效能：背景預熱 + 快取（別在按列印時當場查）
當場查 `~HS` 要等 notify 回來（~3s），列印鈕會卡。改成：
- **連線後 + 每次列印後**，在**背景**送一次 `~HS`、解析、把結果存進 `_ready` 快取（`prewarm()`）；
- **列印鈕只讀快取**（`readyCached()`，瞬間），快取顯示缺紙/開蓋就擋下並提示。

---

## 中文 / CJK 標籤：走「點陣圖」列印 + 內嵌 CJK 字型

熱感機用內建字型印中文常常亂碼或缺字。**可靠做法：把整張標籤在後端算成點陣圖（bitmap），
用 TSPL bitmap 指令直印**——所見即所得，中文保真。

- 標籤 40×20mm ≈ 320×160 dots（203dpi）。一張 bitmap 約 6KB。
- **產圖時務必用「內嵌的 CJK TTF 字型」**（如思源/微軟正黑）畫字。踩雷：reportlab 的 **CID 內建 CJK 字型
  經 fitz/PyMuPDF 轉出會亂碼**，要換成明確嵌入的 TTF 才正常。

---

## 連線只綁「單一網頁」：換頁必斷，靠 autoConnect 各頁重連

Web Bluetooth 的 GATT 連線**綁在單一網頁**，換頁 / 重新整理**必斷**，做不到「連一次、跨頁全程不斷」。

- 務實解法 = **autoConnect**：授權配對一次後，之後每進一個需要列印的頁面就**自動重連**（1~2 秒），
  使用者不用每次重選裝置。
- **離開頁面前主動、乾淨地斷線**（停 notify → disconnect）。低階手機若帶著作用中的 GATT 連線卸載頁面，
  渲染器可能崩潰（閃退）。所以 `pagehide` 要收乾淨。
- `requestDevice()`（跳配對選擇器）**規定要有使用者手勢**：必須放在按鈕 onclick 內、且是該 handler 裡
  第一個 `await`，否則瀏覽器會擋。

---

## 失敗過、別再試（反面清單）

- ❌ 逐張各自 `write()` 連印 → 第 2 張 `GATT error unknown`（status 133）。→ 改「合併一份、一次 write」。
- ❌ 純 NR、只結尾 ACK 一次 → 緩衝爆、印一張就卡死。→ 改「NR + 每 N 塊 ACK」。
- ❌ `Promise.all` 並發抓圖 → 後端互卡、前端停在「取圖中」。→ 改「逐張循序抓」。
- ❌ 用 ESC/POS `DLE EOT` 查 P3301B 狀態 → 不回應。→ 用 TSPL `~HS`。
- ❌ 按列印時當場查 `~HS` → 卡 ~3s。→ 背景預熱 + 快取，列印鈕只讀快取。
- ❌ 狀態偵測失敗就擋列印 → 換機種/沒 notify 會全部印不出來。→ fail-open。

---

## 換到別台印表機機種怎麼移植

通用原則（鐵則 1~3、CJK 點陣、換頁重連）照用。**就緒偵測的欄位對應要重新實測推導**：

1. 準備一個「查狀態」測試頁：送查詢指令、訂閱 notify、把收到的 bytes **以 ASCII 還原顯示**（不要只給 hex，
   人工比對很痛苦）。
2. 先確認機種吃哪套協定：TSPL 送 `~HS`、ESC/POS 送 `DLE EOT`，看哪個會回。
3. **實機跑三種狀態各記一次回應**：正常（裝紙蓋好）／缺紙（蓋好）／開上蓋。
4. **逐欄 diff** 三份回應，找出「哪一段哪一欄」在缺紙時變、在開蓋時變 → 推出真值表。
5. 把新真值表寫進解析函式，並**用新的裝置名 regex 認型號才套**、其餘 fail-open。

（reference 實作把上述查狀態頁做成 `/bt_test`，「收到通知」附「解讀」行做 ASCII 還原，方便逐欄比對。）

---

## 名詞對照
- **NR** = `writeValueWithoutResponse`（無回應寫入，快）
- **ACK** = `writeValueWithResponse`（有回應寫入，等印表機收妥＝流控）
- **TSPL** = 標籤機常見指令集（`~HS` 查狀態、bitmap 指令直印）
- **`~HS`** = TSPL「Host Status」查詢，bytes `7E 48 53 0D 0A`
- **fail-open** = 偵測不確定時「放行」（照印），而非「擋下」

