本篇階段:Prj#3 硬體串接
使用介面:Claude Code(via VS Code)
前面兩個專案,資料都在這邊:
程式如果有問題或跑不出東西,原因一定在自己寫的程式碼裡面。
第三個專案換個題目類型:要怎麼串接硬體?在 Claude Code 進行硬體串接測試的時候可能會遇到什麼問題?它有辦法幫助我解決這些問題嗎?
手腕上這支小米手環 6 每分每秒都在量步數、心率,但它沒有任何義務把這些資料交給我的筆電。它有自己的一套規矩,規矩不是我訂的,也沒有一份官方文件可以查。
今天不寫功能,先把幾個前提交代清楚:這個專案為什麼只能在本機做、BLE 到底在傳什麼、bleak 在這中間是什麼角色,還有為什麼從一開始就決定只讀不寫。最後把開工前的判斷寫成一張表,等後面幾天實測回來對。
前兩個專案其實都可以在 claude.ai 上做完,只是麻煩。程式碼貼過去、錯誤訊息貼回來、輸出的表格再貼過去,一來一往雖然慢,但資訊沒有損失,因為所有東西都是文字,而且都在看得到的地方。
這專案不行,因為回饋的來源變了。
第一,真正的判斷依據只能在筆電的藍牙晶片上。「手環現在有沒有在廣播」這件事,沒有辦法用描述的方式傳給一個看不到那台機器的模型。掃描結果雖然可以貼過去,但那本身就是程式跑出來的東西,程式還沒寫好之前,連要貼什麼都不知道。這是雞生蛋、蛋生雞的問題:要拿到第一手資訊,得先有一支能拿到第一手資訊的程式。
第二,硬體的錯誤訊息資訊量低得誇張。程式炸掉會給一整串 traceback,看得出是哪一行、什麼型別對不上。BLE 炸掉常常只有一句「連線失敗」,後面接一個 WinRT 的錯誤碼。這種訊息轉述不太方便,要它變有用,只能靠「在同個環境裡再跑一次、多印一些資訊出來」,而那是一個要跑很多輪的訊息往復過程。每一輪都要人工搬運文字的話,實在有點太不「智慧」。
第三,狀態不可攜帶。手環同一時間只能被一個中央裝置連著,所以手機上的 Zepp Life 有沒有佔著,直接決定筆電連不連得上。除此之外還有:Windows 有沒有快取上一次探索到的服務結構、手環螢幕是不是亮的、人跟筆電距離多遠。這些狀態描述給模型聽的難度有點高,且前提是我得先知道哪幾個狀態重要,而在這種探索的例子上,我也不知道。
所以分工其實很單純:需要一個能在這台機器上跑程式、看得到 stdout、改完可以在同一個 session 裡馬上再跑一次的東西。這些落差正好是 Claude Code 補得起來的。前兩個專案選它,很大一個原因是方便;這一個則是因為別的選項做不到。
不過有一半的事它還是做不了,這也要先講清楚。手環戴在手上,走幾步讓步數變、把手機的藍牙關掉、把手環從充電座拿起來,這些都得由人來做。所以這一輪的節奏跟 Prj#2 那種「丟出去、等結果」完全不同,變成一種我也要隨時參與其中的模式:它把程式改好、按下執行,然後停下來說「請在十秒內走動幾步」,我照做,資料才會出現,整個過程我需要在筆電附近跟它互動、聽它的指揮。實際上這樣做,讓程式修改完到看到結果之間只有幾十秒到幾分鐘。
環境的部分沿用 Prj#2 的習慣:Windows 11、conda 環境、VS Code 裡開 Claude Code。相依只有兩個套件,bleak 和 ttkbootstrap,版本都釘死。釘死的理由在第 3 節。
低功耗藍牙跟傳統藍牙(那種傳音訊、傳檔案的)是兩套不同的東西,共用一個名字而已。BLE 的設計前提是「大部分時間睡覺,偶爾醒來講一句很短的話」,所以它的整個結構都在為省電服務,也因此看起來跟一般人想像中的連線不太一樣。
還沒連上之前,手環在做的事情叫廣播(advertising):每隔一段時間丟一個很小的封包出去,內容大概是「我在這裡,我叫這個名字,我有這幾種服務」。這個角色叫周邊(peripheral)。筆電這邊在掃描(scanning),角色叫中央(central)。
傳統的廣播封包上限只有 31 個位元組,裡面能塞的東西非常有限。實際掃到的時候,拿得到的大致是這幾樣:
想當年,第一次在公司辦公室跟新竹租屋處跑掃描才知道環境有多吵。就算是一輪八秒,也可能會掃到一百多個裝置。
現在的耳機、電視、冷氣遙控、防丟器、體重計、鄰居家的一切,只要支援 BLE 就在廣播,而廣播是不需要對方同意的。大部分裝置為了省電或隱私不放名稱,於是掃出來就是一長串只有位址跟訊號強度的東西。掃到幾個會依情況與位置而不同,但有名字的永遠是少數。
這件事直接影響了程式的預設值:第一版就加了一個「只列出有名稱的」開關,還有依訊號強度由強到弱排序。排序那條是 Claude 後來自己加的,因為我跟它說我現在就把手環放在筆電旁邊,把最強的排前面等於先做了一輪篩選。這種細節不寫進程式的話,每次掃完都要人眼在一百多列裡面找。
回宜蘭的時候我也試過,果然是鄉下地方,在旁邊都是田的情況下,藍牙真的少很多XD
連線建立之後,兩邊講話的規則叫 GATT。它其實就是一個很小的、唯讀居多的資料庫,分三層:
每一層都有一個 UUID 當身分證。標準定義好的那些會寫成四位十六進位的短碼,像 180F 是電池服務、2A19 是電池電量,但那只是縮寫,實際在線路上跑的是完整的 128 位元 UUID,展開規則是把短碼塞進藍牙的基底 UUID:
0000xxxx-0000-1000-8000-00805f9b34fb
廠商自訂的東西不能用這個基底,得用自己的。華米(也就是小米手環背後那家公司)用的是這一組:
0000xxxx-0000-3512-2118-0009af100700
看到這串就知道進入非標準區了。標準區的東西可以查表,非標準區的只能靠社群已經公開發表的筆記,或者自己一格一格讀讀看。
開工前 Claude 自己先做了一張對照表寫進程式,把已知的短碼對應到中文說明,這樣列舉出來的東西才看得懂。標準的部分大概是這些:
| 短碼 | 說明 |
|---|---|
1800 / 1801 |
通用存取、通用屬性,每個 BLE 裝置都有 |
180A |
裝置資訊,底下放型號、序號、各種版本 |
180D |
心率 |
180F |
電池 |
2A00 |
裝置名稱 |
2A19 |
電池電量,一個位元組的百分比 |
2A25 |
序號 |
2A2B |
目前時間 |
2A37 |
心率量測(notify) |
2A39 |
心率控制點(要寫入) |
華米私有的那一堆全是轉述來的,可信度跟標準區不同,所以在表裡另外標注:
| 短碼 | 社群筆記上的說明 |
|---|---|
0001 / 0002 |
韌體上傳控制、韌體資料 |
0003 |
使用者設定 |
0004 |
活動資料 |
0006 |
電池詳情 |
0007 |
即時步數 / 活動 |
0009 |
認證挑戰 |
0010 |
感測器資料 |
這張表的用途不是拿來讀資料,是拿來讀「列舉出來的清單」。沒有這張表,跑出來的八十幾行 UUID 長得都一樣,得一行一行去查才知道哪幾行值得追。
每個特徵身上帶著一組屬性(properties),寫明允許哪些操作,常見的有這幾種:
read:可以主動去讀。write:可以寫,而且會回覆成功或失敗。write-without-response:可以寫,但不回覆。notify:裝置有新值的時候會主動推過來,不用問。indicate:跟 notify 一樣是主動推,差別在收到的一方要回一個確認。notify 不是連上就自動開的。要開,得往那個特徵底下的描述元(UUID 2902,全名是用戶端特徵設定描述元)寫一個值進去,裝置才知道有人在聽。這一步 bleak 的 start_notify 會自己處理掉,但知道底下發生什麼事還是有差別,因為訂閱失敗的錯誤訊息,實際上是那一次寫入被拒絕。
順帶一提 notify 為什麼存在。從省電的角度看,「每秒去問一次有沒有新資料」對周邊來說是最糟的模式:它得一直醒著等人問。改成有新值才推,周邊可以睡到有事發生才醒。所以在 BLE 的世界裡,會變動的資料通常只給 notify 不給 read,那不是刻意刁難,是設計上的取捨。
這一節是整篇的伏筆。
一個特徵讀不到,可能有好幾種完全不同的原因,而它們在 ATT 這一層有各自的錯誤碼:
0x02 Read Not Permitted:這個特徵在屬性上就沒開放讀取。不是權限不夠,是根本沒這個功能。0x05 Insufficient Authentication:要先配對過。0x08 Insufficient Authorization:配對過了,但這個裝置認為還不夠格。0x0F Insufficient Encryption:這條連線要先加密。差別很重要。第一種是「這扇門是牆上畫的」,後面三種是「門在那裡,但沒有鑰匙」。做可行性評估的時候把這兩類分開,才知道哪些是選擇要不要付代價、哪些是再怎麼想辦法都沒有。分不清楚的話,會在牆上鑽很久。
至於這支手環實際上會用哪一種方式拒絕,等真的連上去讀一輪才知道。
還有一件事得先講,因為它會影響後面所有的驗收方式。
前兩個專案的測試很單純:同一份 Excel 餵進去,跑幾次都是同樣邏輯推出的答案。而現在同一支程式對同一支手環跑兩次,可能一次連上一次連不上,可能一次讀到十二個欄位一次讀到十一個。原因不在程式裡,在下面這幾件事:
所以這個專案沒辦法用「跑過就是對的」當驗收標準。能做的是兩件事:一是把每一次跑的結果原樣留下來,二是把「不需要手環也能驗」的部分切出來單獨測。真的需要手環在場的那一段,只能承認它是單次觀察,而不是可重複的實驗。
這個限制不是這支手環特有的,任何無線的東西都一樣。認清它比較重要的原因是:不認清的話,一個時好時壞的功能會被當成 bug 追很久,而那個 bug 根本不在程式裡。
bleak 是一個 Python 的 BLE 用戶端函式庫。用戶端這三個字要強調:它只能扮演中央,也就是去掃描、去連別人,沒辦法讓筆電變成一個被連的周邊。這次的題目剛好只需要前者。
它的價值在於把三個作業系統的差異藏起來:Windows 底下走 WinRT、macOS 走 CoreBluetooth、Linux 走 BlueZ,上層的 API 長得一樣。藏得掉的是呼叫方式,藏不掉的是行為差異。
整個 API 是 async 的,所有 I/O 都要 await。這不是趕流行,是因為 BLE 本質上就是一堆等待:等掃描逾時、等連線建立、等對方推封包過來。用同步的寫法會變成一連串 sleep。
這次釘的是 bleak==2.0.0。開工前先花時間確認版本,是因為網路上找得到的範例大部分停在 0.2x 那個年代,而中間累積的變動不少:
get_services() 標記為棄用。None,不是一個空集合。這對這個專案有一個直接的影響。語言模型的訓練資料裡,舊寫法的量遠遠大於新寫法,這在寫程式的時候很容易變成一種安靜的退化:程式看起來很像對的,跑起來噴一個看不懂的錯。所以開工的規矩裡就把版本寫死,並且要求所有寫法以安裝在環境裡的那一版為準,有疑問去讀套件原始碼,不要照記憶寫。
依我的經驗,有先寫這條規矩可以為後來省事,不過通常還是難免會有漏網的地方。
挑這支手環的理由其實很簡單:手上就有一支。
小米手環的私有服務需要通過華米自己的一套認證才會放行,這件事在開源社群裡是公開的知識。認證的大致流程是:拿到一組跟帳號綁定的十六位元組金鑰,連上之後往認證用的特徵送一個請求,手環丟回一段亂數當作挑戰,用那把金鑰把亂數加密後回傳,對得上才算數。Gadgetbridge 這類開源專案就是這樣做的,這是讀來的,不是試出來的。
換句話說,這支手環是一個會主動守門的裝置。相較之下如果隨手挑一個沒有任何保護的溫濕度感測器,寫起來會很順,因為所有東西都給我,但不會知道「不給」長什麼樣子。
那為什麼不去弄一把金鑰、把認證做完?我考慮過,最後決定不做。
最主要的理由是法律那條線在哪裡我不確定。掃自己周圍的公開廣播、讀規範上定義成公開資訊的欄位、對照別人已經公開發表的筆記,跟自己動手把手環裡面的東西拆開來逆向、再把改過的內容寫回去,這兩件事的性質差很多,但中間的界線落在哪一格,我不確定。使用者條款怎麼寫、著作權法的還原工程例外涵蓋到什麼程度、走到哪一步會變成規避技術保護措施,這些問題我都沒把握。如果還把過程寫成文章、附上可以直接跑的程式碼,那就不是一個適合去試探邊界的場合。所以只會做兩件事:讀公開的廣播,讀免認證的欄位;而手環的內容不逆向、不改、不寫入。
另一個理由比較現實:取得金鑰的正常途徑要把手環從官方 App 解綁再重綁,過程中會清掉一部分設定。
最後,寫入私有特徵這件事本身有風險。從別人整理出來的對照表可以看到,那個服務底下有兩個特徵是韌體上傳用的。我不打算刷韌體,但不打算跟不會誤觸是兩回事,一支會寫入的程式跟一支不會寫入的程式,出包的可能性不同。
定下這條界線有一個附帶好處:驗收變得很好做。任一行 write_gatt_char 出現在改動裡就算失敗,這種可以用字串搜尋來檢查的標準非常實際。
硬體題最貴的成本是猜:GATT 那張表沒有列出來之前,任何一行「讀心率」的程式碼都是在猜 UUID。
所以第一步是寫一支只會列舉的工具:掃描、連線、把整棵 GATT 樹印出來、把所有可讀的欄位都讀一遍、把所有可訂閱的欄位都訂一遍。這支工具本身沒有任何功能可言,它的產出就是一張清單。有了清單才知道下一步做什麼。
開工前先分成三類:
應該拿得到的:標準 GATT 那幾個。1800 通用存取底下的裝置名稱、180A 裝置資訊底下的序號跟版本、180F 底下的電池電量。這些是藍牙規範裡定義成公開資訊的東西,理論上不需要認證。
可能拿得到的:華米私有服務底下的少數幾個。從社群筆記看,電池詳情跟即時活動這兩個似乎沒有那麼嚴,但沒有把握。
應該拿不到的:心率。規範上訂閱 2A37 就該有值,控制點 2A39 在標準裡的用途是重置消耗熱量,不是啟動量測。但這支手環要不要先下一道指令、那道指令走不走私有服務,開工前我不確定。唯讀模式下什麼都不寫,所以先歸到拿不到那一類,等實測。
整理成一張表大概是這樣:
| 想要的東西 | 位置 | 開工前的判斷 | 理由 |
|---|---|---|---|
| 裝置名稱、序號、版本 | 1800 / 180A |
應該拿得到 | 規範定義為公開資訊 |
| 電池電量 | 180F / 2A19 |
應該拿得到 | 同上 |
| 電池詳情 | 華米 0006 |
可能 | 社群筆記說不需認證,沒把握 |
| 手環時間 | 2A2B |
應該拿得到 | 標準特徵 |
| 即時步數 | 華米 0007 |
可能 | 同電池詳情 |
| 心率 | 180D / 2A37 |
拿不到 | 唯讀模式下不寫入,能不能只靠訂閱拿到還不確定 |
| 歷史活動資料 | 華米 0004 |
拿不到 | 私有服務主體,必然要認證 |
先把判斷寫下來,實測之後再對應看看。
這種「先寫預測再實測」的做法,可以看出哪些是自己原本就沒搞清楚的,哪些是根本理解錯的。
風險盤點部分:
最後這一條其實是整個設計的核心,這支工具的目的不是「讀出電池電量」,是「告訴我這支裝置有什麼」。前者寫死幾個 UUID 就好,後者必須從列舉出發。
Day 15 學到的那件事在這裡有直接的應用:腦子裡綁在一起的東西,沒寫進去就不算數。所以這次動手之前先把幾條規矩寫清楚,大致是這幾項:
bleak==2.0.0,寫法以環境裡實際安裝的版本為準。還有一件事沒寫進規矩但值得記下來:這次沒有一開始就要求做圖形介面。上個專案是一路做到打包,這次刻意先停在命令列,因為圖形介面會把錯誤吃掉。視窗程式的例外預設不會出現在任何地方,而 BLE 這種東西的錯誤又特別隱晦。先讓所有東西都印在主控台上,確定拿得到資料,再談介面。
今天一行程式都沒寫,做的是幾件事:認清這專案為什麼只能在本機做、把 BLE 講到夠用的程度、決定用哪個版本的 bleak 並說明理由、把唯讀這條界線定死,最後寫下一張「哪些拿得到、哪些拿不到」的預測表。
其中我覺得最有價值的是第 2.4 節那張錯誤碼表格。做硬體串接失敗的方式有兩種,一種是門在那裡但沒鑰匙,一種是牆上根本沒有門。這兩種要用完全不同的態度對待:前者是選擇要不要付代價,後者是接受它就是不存在。
另一個比較不舒服但躲不掉的收穫是 2.5 節。「跑一次看結果對不對」這種做法碰到無線裝置就會開始騙人,一個時好時壞的功能可以讓人在程式裡找很久,而問題根本不在程式裡。
回到開頭那三個問題。「要怎麼串接硬體」今天大致有答案了:先把協定讀到夠用、把界線定死、把預測寫下來,然後才開始寫程式。剩下兩個問題:測試的時候會遇到什麼、Claude Code 幫不幫得上。這兩個到明天跟後天才會有實際的測試與答案。
明天看這支工具實際長出來的樣子,包括 UI 設計稿。
藍牙官方規範
Python 套件
開源社群
其他參考
註一:本文對 BLE 的說明刻意簡化到「做得出這個專案」的程度,要做正式的產品開發請以藍牙核心規範為準。
註二:文中關於華米認證流程與私有 UUID 的描述,全部來自開源社群已經公開發表的資料,我本人沒有實作也沒有驗證,屬於轉述。這個專案沒有對手環做任何逆向或修改,也沒有嘗試認證。本專案刻意設計為唯讀,不對手環寫入任何資料。任何嘗試寫入私有特徵或韌體相關特徵的行為都有把裝置弄壞的風險,請自行評估。
註三:所有觀察都在同一台筆電上做的(Windows 11、conda 環境、Python 3.13、bleak 2.0),屬於定性觀察不是效能評測。同一支手環在不同的韌體版本、不同的作業系統上,行為可能不同。
iThome鐵人賽