本篇階段:Prj#3 硬體串接
使用介面:Claude Code(via VS Code)
Day 17 寫了一張預測表,把想拿的東西分成「應該拿得到」、「可能」、「拿不到」三堆,Day 18 把工具做出來。今天把手環連上去,看那張表有哪些對錯。
大方向都對,但有一格跟預期相反。另外沒料到的是,這一輪的除錯時間絕大部分跟藍牙一點關係都沒有,全花在視窗、執行緒跟關閉流程上。
這個專案的程式碼、UI 設計稿與測試腳本請參考我的 GitHub 對應的專案連結
整輪在 2026-08-30 下午跑完,只裝 requirements.txt 那兩行。命令列版的選單是互動式的,所以另外寫三支腳本按同樣的順序自動跑一遍,解碼一律 import 命令列版的函式,免得紀錄檔跟工具講的內容不一樣。
| 項目 | 結果 |
|---|---|
| 掃描 | 八秒沒掃到,二十秒掃到,RSSI −53 dBm |
| 連線 | 9.9 秒 |
| GATT | 11 個服務、46 個特徵、28 個描述元 |
帶 read 屬性的特徵 |
29 個 |
| 讀取成功 | 25 個(其中 7 個回來是零位元組) |
| 讀取被拒 | 4 個,全部是 0x02 Read Not Permitted |
| 可訂閱的特徵 | 26 個 |
| 訂閱成功 | 24 個 |
| 兩分鐘內真的推東西過來的 | 1 個 |
八秒沒掃到、拉到二十秒才出現,這是 Day 17 那條「螢幕暗掉之後廣播間隔會拉長」的實際情況:不是連不上,是八秒的窗口沒跟它的廣播碰到。
把可讀的都讀一遍,收斂成程式裡的那份摘要。這七個欄位是跑過一輪、確認免認證真的拿得到才寫進去的,不是照規範抄的:
| 欄位 | 特徵 | 讀到的 |
|---|---|---|
| 裝置名稱 | 2A00 |
Mi Smart Band 6 |
| 序號 | 2A25 |
32**********74(十四個字元,中間遮蔽) |
| 硬體版本 | 2A27 |
V0.82.114.3 |
| 韌體版本 | 2A28 |
V1.0.6.20 |
| 電池電量 | 2A19 |
78 % |
| 電池詳情 | 華米 0006 |
電量 78 %、未充電、兩個時間戳、上次充電結束 99 % |
| 手環時間 | 2A2B |
2026-08-30 14:25:44 星期日 |
手環時間比我想的有用。裝置資訊那幾格是固定的,兩輪讀出來一模一樣;時間是變動的,跟當下的電腦時間對得起來,代表從掃描到解碼整條路徑都沒錯位。電池那組是另一種驗證:同一個 78 出現在兩個地方,標準的 2A19 是單獨一個位元組,華米的 0006 是二十個位元組裡的第二個。不同服務、不同格式,講同一件事。
摘要一開始是照標準 GATT 表排的:裝置資訊底下該有型號 2A24、韌體版本 2A26、硬體版本 2A27、軟體版本 2A28、製造商 2A29。實際上 2A24、2A26、2A29 在這支手環上根本不存在,列舉時就沒有這幾個特徵;而韌體版本裝在 2A28,那一格在標準裡叫軟體版本。
# 欄位依 Mi Band 6 實測結果挑選:手環沒有 2A24/2A26/2A29,
# 韌體版本實際上放在 2A28(軟體版本)。
標準 GATT 定義的是「如果有這個欄位,它的意思是什麼」,從來沒有定義一定要有哪些欄位。規範給的是一套詞彙,不是一份清單,所以探索工具只能先列舉再解讀。
序號跟 MAC 位址在紀錄檔跟截圖裡都遮掉了,程式讀到的還是完整值。第一版只處理「顯示位址」那幾個欄位,實際上同一組位元組還躲在廣播的廠商資料尾端跟 2A23 System ID 裡,肉眼掃過去只覺得是一串亂碼。這跟 1.1 同狀況:以為處理完了,是因為只檢查了自己想得到的那幾個位置。
Day 17 第 2.4 節把拒絕分成兩類:牆上沒有門,跟門在那裡但沒鑰匙。實測多出第三種。
第一類是屬性上就沒開放讀取,列舉時根本沒有 read,四十六個特徵裡有十七個,華米那幾個韌體上傳、使用者設定、活動資料都在裡面,程式連試都不會試。
第二類是有 read、讀下去卻被打回來,四個被拒的全是一樣的錯誤碼:
Could not read characteristic handle 43: Protocol Error 0x02: Read Not Permitted
心率控制點原本以為屬於第一類,實際列舉出來屬性是 [read,write],讀下去才被擋。屬性表講的是這個特徵宣告了什麼,不是它會給什麼。
第三種是原先沒預料到:讀得到,但裡面是空的。華米認證服務底下那幾個加上私有的 0013,一共七個,讀取沒被拒,回來零個位元組。我把它們留在成功那一堆,因為從 ATT 那一層看確實成功了,但輸出會把「0 bytes」寫出來,藏起來就變成 Day 17 說的那種假裝一切正常。
心率是一條完整走到底的死路。原本以為量測值 2A37 至少訂得上,只是沒人叫感測器開工所以沒東西,實際上連訂閱都過不了:
Could not start notify on 0028: Protocol Error 0x03: Write Not Permitted
0x03 是寫入被拒,不是讀取被拒。start_notify 底下是往 2902 描述元寫一個值,手環擋掉的是那次寫入。所以一個唯讀的程式,連開啟訂閱都可能被擋,因為它本質上不是唯讀的。
順帶一提,例外處理原本只接 BleakError,跑起來 Windows 後端有時候會讓底層的例外直接穿過來,接不到就整個中斷。跨平台函式庫把 API 統一了,例外的型別統一不了。
Day 17 那張表裡,即時步數我填的是「可能」。實測:它禁止直接讀取,但只要訂閱,免認證就會一直推過來。那張表少了它會自己送過來的那一類。
0c 49 00 00 00 11 00 00 00 01 00 00 00
第二到第五個位元組小端序讀出來是 73,當下的步數。後面那幾格可能是距離跟卡路里,但沒把握就不猜,程式輸出直接寫「其餘位元組未解析」。
回頭看,在 Day 17 第 2.3 節就寫過理由:BLE 的省電邏輯是會變動的資料通常只給 notify 不給 read,步數每走一步就變,它當然只給 notify。我做可行性評估的時候把這個知識點寫進去了,卻沒接到「所以有些欄位讀不到不代表拿不到」這個結論上。這跟 Day 15 是同一種失誤的變體。
最後這變成介面上的一行字:摘要跑完,即時步數那一列不是印「讀取失敗」,是印「禁止直接讀取,請用 Start Notify」。
戴著手環走兩分鐘,五十五個封包,步數從 73 走到 182。間隔中位數 0.84 秒,封包之間步數大多加 2,所以它不是固定頻率的心跳,是有變化才推。
那幾格沒解的位元組中途也自己動過一次,看起來像手環把距離重算了,但那只是看數字的聯想,程式輸出還是維持「其餘位元組未解析」。
二十六個候選訂上二十四個,兩分鐘內真的推東西過來的只有即時步數。所以程式在訂閱成功之後會多印一行:
手環未認證時多數欄位不會推送;走動幾步可觸發即時步數。
第一次監看的時候手環放在桌上,九十秒一個封包、步數 0,畫面跟訂閱沒生效一模一樣。「成功但沒有輸出」在做硬體最容易誤導人,因為它跟壞掉長得一模一樣;要不是那行提示先寫進程式,我大概真的會去改訂閱邏輯。
這也是 Day 17 講的「人機互動」最明顯的地方:程式跑起來、訂閱完成,然後換我站起來在房間裡走來走去,回來看數字有沒有變。
這一輪最花時間、也最沒料到的部分。BLE 那一段反而順,該讀的讀到、該被拒的被拒;真正一直炸的是視窗、執行緒跟關閉流程。
| 症狀 | 真正的原因 |
|---|---|
掃描到一半關視窗,噴一串 Event loop is closed |
迴圈被關掉的時候掃描器還在跑,WinRT 那層還在往關掉的迴圈投遞廣播封包 |
coroutine was never awaited |
if self._busy: return 擋掉的是執行不是產生,coroutine 物件早就生出來了 |
關窗之後噴 invalid command name |
紀錄佇列那個 after(50) 還有最後一次排程沒取消 |
bs.PanedWindow 直接 AttributeError |
這個環境裝的只有小寫 w 的 bs.Panedwindow |
| 工作列上是 Python 那隻蛇 | 工作列按 AppUserModelID 取圖示,不設就沿用宿主行程的,而且必須在建立 Tk 主視窗之前設 |
| 切到暗色主題,紀錄視窗還是白的 | 那是 tkinter 內建的捲動文字框不是 ttk 元件,主題切換管不到它 |
第一個最難搞。修法不是去攔那個例外,是讓掃描器有機會自己收尾:先取消所有還在跑的工作、gather 等它們走完 finally,然後才停迴圈。
"""取消所有進行中的 task 並等它們收尾。
關鍵:掃描/連線中途關窗時,必須讓 bleak 的 finally 有機會停掉 WinRT
掃描器,否則迴圈關閉後掃描器仍會投遞廣播封包,噴 Event loop is closed。
"""
這個坑值得記錄:錯誤訊息指的是迴圈被關了,真正該修的是有東西還沒停就關了迴圈。第二個也一樣,警告說有一個 coroutine 沒被等待,真正的意思是擋門的邏輯漏了一個資源要收,修法就一行 coro.close()。
六個沒有一個是 BLE 的問題。我原本以為問題會是通訊協定,結果協定讀規範加上社群公開的筆記就解決了大半;跟裝置講話不難,難的是講到一半有人按了關閉。
第 4 節那些會噴錯,比較好抓。這一節不會噴錯,更麻煩。
按鈕的啟用狀態同時受兩件事影響:有沒有連線、現在忙不忙。第一版是記下忙碌前的狀態、結束時還原,遇到忙碌期間連線斷掉就壞了,還原出來是已連線的樣子,實際上已經斷了。後來改成結束時不還原,重新依照當下的連線狀態算一次。前者是記憶,後者是重算;有兩個以上的狀態來源時,記憶一定會有不同步的時候,重算不會。
關閉也一樣,它不是一個動作,是五個有順序的步驟,每一步都對應到第 4 節某一個曾經炸掉的東西:取消排程中的輪詢、關紀錄檔、斷藍牙並給五秒上限、收事件迴圈,最後才銷毀視窗。
亮色,跑完摘要之後:

跟 Day 18 那張 Main 稿對照,三段式版面是照著做的;右上角那顆綠色膠囊沒做出來,換成左下角的 Connected 加粗。暗色這張順便驗了主題那個坑,紀錄視窗跟著介面一起變黑:

GATT 結構分頁展開的樣子,那八十五個節點的實際長相,也是這棵樹要單獨開一個分頁的理由:

Day 18 欠的那一項。當時停住的理由是工具還在天天改,而 --noconsole 出來的程式沒有標準輸出;實測跑完、東西不再動,理由就不成立了。
圖示沿用 Prj#2 的做法,寫一支 tools/make_icon.py 進版控,用 Pillow 在 1024 像素的畫布上畫,降採樣成六種解析度包進 .ico。真正的驗收在 16 像素:縮到 16 等於除以 64,第一版三個形狀擺得太近就糊成一塊,而 print(im.ico.sizes()) 印出六個尺寸完全看不出這件事,它只證明檔案裡有那幾張圖,不證明那幾張圖看得懂。
打包設定沿用 Prj#2 的 .spec,那裡面有一行是把 conda 放原生 DLL 的 <sys.prefix>\Library\bin 塞進 PATH,少了它 .exe 一啟動就死在 DLL load failed while importing _ctypes。但一起抄過來的七行 winrt.* hiddenimports 拆掉試了一次,exe 照樣開得起來、照樣掃得到裝置,從頭到尾沒有作用。上一個專案的 .spec 裡有些行是血換來的,有些行只是當時的猜測沒人回頭刪,兩種長得一模一樣。
建出來是一個 21.5 MB 的檔案,第一次執行 7.4 秒才跳出視窗,之後穩定在四秒半。打包最容易騙人的地方是失敗跟成功都回傳 0,所以驗收條件寫死成三條:建置紀錄裡 Library not found 零次、.exe 雙擊之後真的跳出視窗、視窗裡按一次掃描真的掃得到東西。第三條是這個專案特有的,BLE 後端整包走 winrt,正好是最可能在打包時被漏掉的部分。實際按下去八秒掃到十三個裝置。
交出去的其實不只是一個 exe。對方拿到的是一個檔案,雙擊之後跳出一個滿是英文按鈕的視窗,上面寫著 GATT、UUID、RSSI,所以另外補了兩份說明:一份講架構、關閉那五步為什麼不能換順序,寫給之後可能後回來改它的人(很可能就是我自己);一份是使用者指引,前提硬到假設對方手上只有 exe 跟那份指引,裡面不能出現任何檔名、路徑、開發環境的名字。意外的收穫是後者逼我把「這不是故障」寫出來:Read All 會噴四行紅字、開 Notify 等半天沒動靜、Summary 最後一行永遠是黃色的。
那張預測表最後對了大部分,錯的那一格教到的東西比對的那些多。錯的方式不是「以為拿得到結果拿不到」,是「以為只有拿得到跟拿不到兩種」,而判斷它需要的知識我在 Day 17 就寫在文章裡了。
Day 17 開頭那三個問題,到這裡可以收了。
怎麼串接硬體:先列舉再解讀。第 1.1 節那個標準表就是反例,規範定義的是詞彙不是清單,做的時候還是會不自覺把表當成現實。
測試會遇到什麼:跟裝置講話往往不是最難的。八成的除錯時間花在把一個會自己斷線、跑在另一條執行緒上的東西塞進同步的視窗程式裡,而且要能在任何時刻被關掉。
Claude Code 幫得上什麼:短迴圈,改一行、跑一次、看主控台、再改一行,而它剛好只在本機做得到。幫不上的是手環要不要戴在手上、圖示縮到 16 像素好不好看。硬體專案的迴圈裡永遠卡著一個人,這不是工具的缺陷,是題目的樣貌。
Prj#3 到這裡告一段落。
Claude Code 其實能做的真的很多,這三天的內容基本上真的是靠它自己完成的,我只負責戴上手環走了走。
在硬體串接上的難度對語言模型來說一定還是有的,不過像今天甚至發佈了 Fable 5.1 模型了,只能說開發的門檻越來越低,這些模型自我除錯的能力越來越強、走冤枉路的機率也是越來越低。
Python 套件
藍牙規範
打包
Windows
開源社群
註一:第 1 到第 5 節的紀錄、截圖與統計取自 2026-08-30 的一輪實測,跑在 Windows 11、Python 3.13.15、bleak 2.0.0、ttkbootstrap 1.19.0;第 6.2 節的體積與啟動時間量於 2026-08-31、同一台機器、PyInstaller 6.21.0,跑三次取值。都是單次的定性觀察不是效能評測,換一台機器數字就會不同,掃描八秒掃不到、二十秒掃得到這種事下一次跑也可能反過來;不同韌體版本、不同作業系統上的行為也可能不同,不能當成小米手環 6 的通則。
註二:序號屬於裝置識別資訊,文中與紀錄檔、截圖裡的序號與 MAC 位址都做了遮蔽;遮的是呈現,程式讀到的仍是完整值。
註三:華米私有特徵的欄位切法來自開源社群已公開發表的資料加上實機對照,不是官方規範。第 3.1 節對即時活動封包尾端那組位元組的說法只是看數字的聯想,沒有任何驗證,同樣標記為待確認。
註四:本專案刻意設計為唯讀,全程沒有對手環寫入任何資料,沒有嘗試認證,也沒有對韌體做任何逆向或修改。任何嘗試寫入私有特徵或韌體相關特徵的行為都有把裝置弄壞的風險,且法律界線因地而異,請自行評估。
註五:程式碼、UI 設計稿與測試腳本可在專案連結取得。