本篇階段:Prj#3 硬體串接
使用介面:Claude Code(via VS Code)
Day 17 定了兩條界線:唯讀,還有先命令列後圖形介面。今天看這兩條界線底下實際長出來的東西。
Day 17 開頭擺了三個問題,今天輪到第二個:在 Claude Code 上做硬體串接的測試,會碰到什麼跟前兩個專案不一樣的東西。答案有兩塊,一塊在測試怎麼寫,另一塊在介面怎麼定。
前半是命令列版跟三支測試腳本,後半是 UI 設計稿跟圖形介面的骨架。
這個專案的程式碼、UI 設計稿與測試腳本請參考我的 GitHub 對應的專案連結
Prj_3_BluetoothConnection/
README.md
requirements.txt
mi_band_explorer.py
mi_band_explorer_gui.py
device_config.py
device.local.example
.gitignore
unit_test/
test_scan.py
test_connect.py
test_notify.py
ui_design/
main-1x.png
console-dark-1x.png
card-grouped-1x.png
main-html/
兩支主程式,mi_band_explorer.py 是命令列版,mi_band_explorer_gui.py 是圖形介面版,兩支各自完全獨立。device_config.py 只做一件事:決定測試腳本要連哪一支手環,理由在第 4 節。device.local.example 是位址設定的範本,真正填了位址的 device.local 不進版控。unit_test/ 底下三支腳本各測一段,ui_design/ 放三張設計稿加上被選中那張的原始檔。
README.md 是給開發者看的,架構、怎麼跑、每個相依為什麼是它。另外兩份文件,一份講程式流程,一份是給拿到成品的人看的操作手冊。這兩份都要等有東西可交付才寫得出來,留到明天。
requirements.txt 全文如下:
bleak==2.0.0
ttkbootstrap==1.19.0
沒有 numpy、沒有 pandas、沒有任何一個科學運算套件。BLE 的資料是幾個到二十個位元組,能做的運算就是位元位移跟查表,Python 內建的 int.from_bytes 就夠了。Prj#2 那支統計程式光核心相依就三百多 MB,這支跟硬體講話的程式少了一個數量級。
把 ttkbootstrap 那行拿掉,命令列版只靠 bleak 一個套件就能跑。
先做命令列版的理由是除錯:圖形介面裡 tkinter 的 callback 一出事,預設只會在主控台印一行然後繼續跑,視窗上什麼都不會發生。
命令列版的介面就是一個選單,跑起來長這樣:
--------------------------------------------------------------------
狀態: 未連線 目標: 未選定
1) 掃描 BLE 裝置
2) 選擇目標裝置
3) 連線
4) 斷線
5) 列舉 GATT 完整結構
6) 讀取所有可讀 characteristic
7) 讀取單一 characteristic
8) 監看 notify
9) 手環摘要(免認證欄位)
0) 離開
選擇:
九個動作,沒有一個會寫入。這份選單其實就是 Day 17 那份可行性評估的直譯:5 是列舉、6 是把能讀的都讀一遍、8 是把能訂的都訂一遍,9 是把前面幾輪跑出來、確認拿得到的欄位收成一份摘要。
掃描、選目標、連線從一開始就是三個獨立的動作,沒有做成一顆「連上我的手環」。
分開的理由是這三步各自會失敗,而且失敗的原因完全不同:掃不到是手環在睡覺或距離太遠,選不到是名稱沒廣播出來、只能靠位址認,連不上通常是手機的官方 App 佔著。合成一步的話,失敗只會得到一句「連線失敗」,而那三種狀況要做的事情完全不一樣。
連線失敗的時候程式會多印一行:
[X] 連線失敗: ...
提示:手環同時只能被一個中央裝置連線,請先在手機關閉 Zepp Life。
這一行就是 Day 17 那份風險盤點的第一條,原封不動搬進程式裡。風險盤點寫完就擺著太可惜,塞得回程式裡的就塞。
程式裡有一個函式長這樣:
async def ainput(prompt: str) -> str:
"""在執行緒中呼叫 input(),避免阻塞事件迴圈(斷線 callback 才能運作)。"""
return await asyncio.to_thread(input, prompt)
看起來只是把 input() 包了一層。整支程式跑在 asyncio 的事件迴圈上,而 input() 是同步的,直接呼叫會把整條迴圈卡死在那一行。偏偏 bleak 的斷線通知是靠 callback 送過來的,callback 要有事件迴圈在轉才會被呼叫到。所以直接用 input() 的版本會出現這種狀況:手環走出範圍、連線斷了,畫面上什麼都沒發生,直到按了 Enter 之後才一次噴出「裝置已斷線」。
這種問題不會在任何測試裡出現,因為要重現它得把手環拿到另一個房間。也不會有錯誤訊息,因為程式沒有出錯,它只是慢了。
其他幾個設計上的決定,每一個都對應到一個實際碰到的麻煩:
掃描結果依訊號強度排序,預設只列有名稱的。 理由在 Day 17 說過了,一輪掃到一百多個。有一個小細節是編號沿用完整清單的索引,所以過濾之後編號會不連續,看起來有點怪,但這樣輸入編號才不會選錯。
Windows 主控台要先開 VT 處理才有顏色。 做法是去要一次標準輸出的 handle,把虛擬終端機處理那個位元或上去。
每一筆資料都印 hex 加 ASCII。
def hexdump(data: bytes) -> str:
hex_part = " ".join(f"{b:02x}" for b in data)
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in data)
return f"{hex_part} |{ascii_part}|"
原始位元組永遠會印出來,解碼結果印在下一行,前面加一個箭頭。解碼解錯了,原始資料還在,不必再跑一次。ASCII 那一欄看起來多餘,但字串型的欄位一眼就看得出來,不必等解碼器。
拿到 0f 63 00 ea 07 08 0e 0a 30 1e 20 … 這種東西,下一步就是要知道它是什麼意思。標準的欄位可以查規範,華米私有的只能靠社群公開的筆記加上自己對照。
程式裡的做法是一個 decode_value(uuid, data),按 UUID 分支,解不出來就回 None,由呼叫端決定要不要只印 hex。幾個比較有意思的分支:
華米的八位元組時間戳。 格式是:年(兩個位元組,小端序)、月、日、時、分、秒、時區。時區那個位元組以十五分鐘為單位,所以台灣的 UTC+8 是 0x20,也就是三十二個十五分鐘。
year = int.from_bytes(chunk[0:2], "little")
...
stamp += f" (UTC{chunk[7] * 15 / 60:+.0f})"
標準的心率量測欄位。 這個規範定義得很調皮:第一個位元組是 Flag,其中最低位決定後面的心率值是八位元還是十六位元。也就是說同一個特徵,封包長度會變。
if data[0] & 0x01 and len(data) >= 3:
bpm = int.from_bytes(data[1:3], "little")
else:
bpm = data[1]
電池詳情。 這個是華米私有的,實測有二十個位元組。切法:第一個位元組不確定、第二個是電量百分比、第三個是充電狀態,接下來兩段八個位元組各是一個時間戳,最後一個位元組是上次充電結束時的電量。
其中兩個時間戳的確切語意可能是「上次充電開始」跟「上次充電結束」,也可能是別的。程式碼裡的處理方式是這樣:
# Huami 電池詳情(實測 20 位元組):
# [1]=電量%, [2]=充電狀態, [3:11]/[11:19]=兩個時間戳, [19]=上次充電結束電量
# 兩個時間戳的確切語意未經證實,故僅標示為時間戳 1 / 2
輸出的時候就真的印成「時間戳1」跟「時間戳2」,沒有給它們名字。這一點我覺得值得記下來:解碼私有協定本來就是在猜,猜沒關係,但猜要留下痕跡。給一個看起來很篤定的名字(比如「充電開始時間」),之後回來看就會忘記那是猜的,然後拿它去做別的判斷。
順帶一提,二十個位元組這個數字可能不是巧合。BLE 預設的 ATT 傳輸單位是二十三個位元組,扣掉標頭剛好剩二十。不過沒有去驗這件事,也可能純粹是華米就想放這麼多,標記為待確認。
解碼器旁邊還有一個小東西,是把 UUID 換成人看得懂的說明。做法是三層:
def describe_uuid(uuid: str, table: dict[str, str]) -> str:
key = uuid.lower()
if key in table:
return table[key]
builtin = uuidstr_to_str(key)
return builtin if builtin and builtin != "Unknown" else "未知"
先查自己維護的表,查不到就退回 bleak 內建的標準 UUID 表,再查不到就印「未知」。
第三層那個「未知」是刻意留的。為了畫面整齊給它一個猜的名字,那張列舉出來的清單就不能信了。
第二層那個退回機制則是佔了便宜:bleak 內建的表涵蓋整份藍牙標準,這邊那張表只需要放兩種東西,一種是華米私有的(標準表裡沒有),一種是我想用中文寫的(標準表裡有但是英文)。剩下幾百個標準 UUID 完全不用碰。
硬體專案的測試怎麼寫,我原本沒什麼概念,這次是從這個專案了解到的。
三支腳本各測一段:
test_scan.py:只掃描,不連線。驗證廣播封包解析得出名稱、訊號強度、服務 UUID、廠商資料,順便看有沒有掃到小米或華米的裝置。test_connect.py:連上去、列舉 GATT、跑摘要、把所有可讀的都讀一遍。test_notify.py:訂閱所有可訂閱的特徵,監看一段時間。前兩支有一個共同的麻煩:被測的函式是互動式的,中間會停下來問「只列出有名稱的嗎」「要監看幾秒」。測試腳本的處理方式很「工人智慧」,把模組層級那個 ainput 換掉:
async def scripted_input(prompt: str) -> str:
print(prompt)
return ""
m.ainput = scripted_input
所有互動都走同一個函式,換掉一個就全部搞定。當初寫 ainput 是為了事件迴圈,不是為了測試,這算是白撿的。
後兩支還有一個問題:要連線就得知道連哪一支。第一版是把手環的 MAC 位址寫成腳本裡的一個常數,跑起來最省事,但那是一個要推上 GitHub 的檔案,而 MAC 是裝置識別資訊。所以後來拆成三層:命令列參數優先,其次讀同目錄的 device.local,兩個都沒有就退回用名稱去掃第一支小米或華米裝置。
Prj_3_BluetoothConnection/
device.local ← 自己的位址,被 .gitignore 排除
device.local.example ← 範本,這個才進版控
退回名稱掃描那一層是刻意的。設定檔這種做法最常見的失敗是:別人 clone 下來,少一個檔案,程式直接炸。而這支工具本來就會靠名稱找裝置,讓它在沒有設定檔的時候自己走那條路,比丟一個「請先建立 device.local」的錯誤有用。
test_connect.py 裡有一段,跑起來完全用不到手環。這一段是整個專案裡讓我對硬體測試想通最多的地方。
Day 17 的 2.5 節講過,這種專案沒辦法用「跑一次看結果對不對」當驗收標準,因為結果本來就會變。但解碼器不一樣:同樣的位元組進去,就該有同樣的字出來,這是純函式,跟藍牙一點關係都沒有。
所以做法是把某一次實機讀到的原始位元組直接寫死在測試裡,當成基準:
def test_decoders() -> None:
"""離線驗證:資料取自 2026-08-14 對 Mi Band 6 的實機讀取。"""
四筆基準資料,都是實機抓回來的:
| 特徵 | 原始位元組 | 應該解出來的 |
|---|---|---|
2A2B 目前時間 |
ea07080e0a3224050020 |
2026-08-14 10:50:36 星期五 |
華米 0006 電池詳情 |
0f6300ea07080e0a301e20… |
電量 99 %、未充電、兩個時間戳、上次充電結束 100 % |
2A19 電池電量 |
63 |
99 % |
華米 0007 即時步數 |
0cf6010000530100000c000000 |
步數 502 |
這四筆的好處是,手環不在旁邊、藍牙關掉、在別台電腦上,這一段照樣會跑。解碼器改壞了會當場被抓到。而它們又不是編出來的假資料,是真的從那支手環上讀回來的。
第一筆裡面藏了一個小驗證:ea07 用小端序讀出來是 0x07ea,十進位 2026,跟後面的月日對得起來,也跟我實際跑那一次的日期對得起來。解碼私有格式沒有規範可以對答案,這種內部一致的檢查就是唯一能靠的東西。
第二筆跟第三筆要放在一起看。電池詳情的第二個位元組是 63,也就是 99;標準的 2A19 電池電量整包就只有一個 63,同樣是 99。兩個不同服務、兩種不同格式,講的是同一件事。這種交叉對照就是判斷「切法有沒有切對」的方法。
第四筆留到明天講,它是這整個專案裡唯一一個意外。
命令列版確定拿得到資料之後才開始做畫面。跟 Prj#2 一樣先跑 /design,差別在這次要的是三個方向而不是一個定案。
一次只出一個方向的話,看的時候會不自覺地開始修它,而不是問還有沒有別的做法;三張並排,比較的對象變成彼此,取捨才會浮出來。另外照 Prj#2 那次加了一條前提:配色從 ttkbootstrap 的主題定義裡拿,不要自己配。

白底,Bootstrap 的 litera 色票,三段式:上面是通訊紀錄(帶一個 GATT 結構分頁)、中間一條存檔用的按鈕列、下面左右兩欄分別是裝置連線跟探索動作。
沒有任何一格需要想「這個 ttk 做不做得出來」,每一塊都直接對得上一個現成的元件。

深色底,左邊一條固定寬度的側欄把裝置清單跟所有動作收起來,右邊整片留給紀錄,每一行前面掛一個毫秒級的時間戳。
這張算好看,因為 BLE 探索本來就是盯著封包一行一行看,深色終端機的形狀正好對上。

米白底,每一區塊是一張有陰影的圓角卡片,標題掛小圖示,主色改成棕色跟藍綠色。
選 Main,判斷的依據只有一個:哪一張照著做,成品會比較接近設計稿,比較不會冒出意料之外的 UI 問題。
CardGrouped 過不了這一關。理由跟 Day 15 那條同源:ttk 做不出圓角、做不出陰影、做不出卡片之間那種呼吸感。照著做出來會是一個很像但每一處都差一點的東西,而且每一格都要跟 ttk 的樣式系統打架,改一個間距要動三層 style。
ConsoleDark 左邊那條固定側欄在 ttk 裡要用 Panedwindow 或一堆 grid 權重去撐,視窗一縮小就開始互相擠,往後每加一顆按鈕都要重新調權重。
Main 那個三段式版面則有一個好性質:由下往上排,最下面的控制項先佔位置,紀錄區最後才吃掉剩下的空間。這樣視窗縮小的時候被犧牲的是紀錄的高度,按鈕永遠完整,而且加東西不會牽動別的區塊。這個順序直接寫進了程式:
control_frame.pack(side=BOTTOM, fill=X, expand=NO, pady=(12, 0))
log_actions_frame.pack(side=BOTTOM, fill=X, expand=NO, pady=(0, 12))
log_frame.pack(side=TOP, fill=BOTH, expand=YES)
不過 ConsoleDark 沒有浪費。它那套深色配色被整組搬進去當成暗色主題,紀錄視窗的每一種語意(系統訊息、特徵名稱、成功、警告、失敗、次要資訊)在亮色跟暗色各有一組顏色。它那個每行前面掛時間戳的想法也留下來了,用在 notify 的 callback 上,因為監看封包的時候,封包之間隔多久本身就是資訊。
三張設計圖收在 ui_design/,被選上的那張留著原始檔,是一份瀏覽器打得開的頁面,顏色、字級、間距、圓角、陰影都以行內樣式寫在標籤上。
架構沿用 Prj#2 那套 MVP,三層各自的職責寫在檔頭:
Model BandModel / LogRecorder 純 BLE 與檔案 I/O,完全不碰 Tk
View ExplorerView 純 ttkbootstrap widget,完全不懂 BLE
Presenter ExplorerPresenter 綁定兩者,並負責跨執行緒交棒
跟 Prj#2 有一個差別:這次 View 完全不持有 Presenter 的參考。所有使用者操作都是 View 身上的 on_* 屬性,預設是空函式,由 Presenter 在建構時指派進去。這樣 View 可以單獨拿出來跑,什麼都不會壞,只是按了沒反應。
不過真正的難點不是分層,是這一句:
執行緒模型:bleak 走 asyncio、Tk 非執行緒安全,因此 asyncio 事件迴圈跑在背景 daemon 執行緒,所有結果丟進 queue.Queue,由 View 以 after() 輪詢後才碰 widget。
這裡有兩個迴圈,兩個都想當主人。tkinter 的 mainloop() 要佔住主執行緒,asyncio 的事件迴圈也要佔住一條執行緒。而且 Tk 的規矩很硬:所有 widget 只能從建立它的那條執行緒去碰,從別條執行緒改一個 Label 的文字,有可能沒事,但出事時直接整個程式當掉。
解法是三段:
asyncio.run_coroutine_threadsafe 把工作丟過去。emit(kind, payload),那個函式只做一件事:把東西塞進 queue.Queue。after(50) 輪詢那個佇列,把東西撈出來才去碰 widget。規矩只有一條:背景執行緒絕不碰任何 widget。
這個結構的好處在斷線的時候特別明顯。bleak 的斷線 callback 是從它自己的執行緒呼叫的,如果在那個 callback 裡直接把按鈕改成禁用,就正好踩到上面那條規矩。走佇列的話,那個 callback 只丟一則 disconnected 訊息,五十毫秒之內主執行緒會撈到它,然後把整組 UI 打回未連線的狀態。
GATT 結構單獨開一個分頁。 這支手環的 GATT 樹有八十五個節點,全部印進紀錄視窗會把其他東西沖掉。所以紀錄區做成兩個分頁,第一頁是通訊紀錄,第二頁是一棵可以收合的樹,服務底下掛特徵、特徵底下掛描述元。列舉完之後紀錄區只留一行「完整結構請看上方分頁」。
存檔分成兩種。 一種是把目前視窗裡的內容一次存下來,另一種是從現在開始邊跑邊寫。後者對這個專案更要緊:監看 notify 可能要開著半小時,而且是 Day 17 講的那種單次觀察,唯一留得住的證據就是當下寫下來的檔案。
圖形介面版沒有 import 命令列版,UUID 對照表跟解碼器各自帶一份。這是刻意的,檔頭寫了理由:本檔可單獨搬走或打包。
代價是兩份要同步,改一個要記得改另一個。好處是這個檔案可以單獨丟給別人,或者單獨拿去 PyInstaller 打包,不用管相對路徑。小專案裡這個取捨還算划算。
Prj#2 是一路做到 onefile 跟 onedir 兩份都建出來、量了體積跟啟動時間。但這一支今天停在原始碼,只先做了該做的準備:資源路徑的處理已經寫成直接執行跟打包後執行都支援,圖示檔案不存在的時候會安靜地沿用預設,不會炸掉。打包本身留到明天,等實機跑過一輪、確定不用改了再說。
回到「硬體串接的測試會遇到什麼」這個問題,今天有兩個答案。
第一個是第 4.2 節那四筆冷凍起來的封包。硬體專案很容易變成「沒有手環就什麼都不能測」,然後測試就自然而然不寫了。但把純函式的部分切出來、餵真實抓回來的資料,就有一段可以在任何地方跑的測試。這一段不會告訴我藍牙有沒有問題,但它會告訴我解碼有沒有被改壞。
第二個是設計稿該照實作代價來挑。這個專案的重點在硬體串接,畫面只要不擋路就好,所以三張稿裡選了最不用跟 ttk 打架的那一張。
至於第三個問題今天還看不太出來,因為今天做的這些事,Claude Code 都很擅長。
明天講實際讀出來的東西,包括第 4.2 節那筆步數 502 為什麼是個意外,還有一連串跟藍牙完全無關的坑。
Python 套件
藍牙規範
Windows
註一:文中所有位元組資料取自 2026-08-14 對同一支小米手環 6 的實機讀取,單次觀察,不同韌體版本或不同裝置的欄位配置可能不同。
註二:華米私有特徵的欄位切法來自開源社群已公開發表的資料加上實機對照,不是官方規範。電池詳情裡那兩個時間戳的語意未經證實,標記為待確認。
註三:本專案刻意設計為唯讀,程式中不存在任何寫入特徵的呼叫。
iThome鐵人賽