藍牙熱感標籤機列印(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= bytes7E 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 點陣、換頁重連)照用。就緒偵測的欄位對應要重新實測推導:
- 準備一個「查狀態」測試頁:送查詢指令、訂閱 notify、把收到的 bytes 以 ASCII 還原顯示(不要只給 hex, 人工比對很痛苦)。
- 先確認機種吃哪套協定:TSPL 送
~HS、ESC/POS 送DLE EOT,看哪個會回。 - 實機跑三種狀態各記一次回應:正常(裝紙蓋好)/缺紙(蓋好)/開上蓋。
- 逐欄 diff 三份回應,找出「哪一段哪一欄」在缺紙時變、在開蓋時變 → 推出真值表。
- 把新真值表寫進解析函式,並用新的裝置名 regex 認型號才套、其餘 fail-open。
(reference 實作把上述查狀態頁做成 /bt_test,「收到通知」附「解讀」行做 ASCII 還原,方便逐欄比對。)
名詞對照
- NR =
writeValueWithoutResponse(無回應寫入,快) - ACK =
writeValueWithResponse(有回應寫入,等印表機收妥=流控) - TSPL = 標籤機常見指令集(
~HS查狀態、bitmap 指令直印) ~HS= TSPL「Host Status」查詢,bytes7E 48 53 0D 0A- fail-open = 偵測不確定時「放行」(照印),而非「擋下」