11.14. 總結¶
你已經從無線電一路走到用來驅動它的 Python API,完整走過了一遍 Bluetooth Low Energy:
動機 ——當相機想和近在咫尺的東西通訊、且兩者之間不需要任何基礎設施時,BLE 就是答案。同一個房間裡的手機、戴在手腕上的穿戴式裝置、牆上的信標。短距離、無需加入網路、幾乎不耗電。
無線電 ——2.4 GHz、40 個通道:三個用於廣播,37 個用於連線資料,以偽隨機序列跳頻並自適應地避開有雜訊的通道。封包簡短、無線電大多在休眠。
連結層 ——封包成框、定址、連線排程、重送以及連結層加密。這些都不是從 Python 設定的;它們全都透過連線參數與 MTU 顯現出來。
Generic Access Profile (GAP) ——探索與連線管理。四種角色:peripheral 與 broadcaster(廣播),central 與 observer(掃描)。廣播酬載承載本機名稱、服務 UUID、外觀以及廠商專屬資料——31 個位元組外加一個選用的 31 位元組掃描回應。連線間隔、peripheral 延遲與監督逾時決定了一條開放連線的使用感受。
Generic Attribute Profile (GATT) ——一棵由服務組成的樹,每個服務持有若干特徵,每個特徵可選擇性地持有描述子,並以 UUID 識別(Bluetooth-SIG 標準用 16 位元,自訂的用 128 位元)。五種操作:read 與 write(拉取,由用戶端發起)、notify 與 indicate(推送,由伺服器發起,透過 Client Characteristic Configuration Descriptor 訂閱)。酬載大小受協商出的 MTU 所限。
Python API ——
aioble把每一種 BLE 模式都轉化為一個 asyncio 協程。一個 peripheral 就是aioble.advertise()在連線上循環,搭配只建立一次、再由aioble.register_services()提交的Service/Characteristic物件。一個 central 則是用aioble.scan()尋找對端、用connect()開啟連結、用service()與characteristic()走訪遠端 GATT 樹,然後用read()/write()/subscribe()/notified()處理實際資料。斷線會在當時正在等待的協程內以aioble.DeviceDisconnectedError的形式浮現。L2CAP 通道 ——為那些不適合 GATT 鍵/值模型的大量位元組串流而設的逃生口。
aioble.DeviceConnection.l2cap_accept()/l2cap_connect()會在 GAP 連線之上開啟一條各應用程式專屬的通道,具備信用流量控制的傳送/接收,以及比 GATT 所能承載更大的 MTU。配對與加密 ——BLE 連結預設是公開的。
aioble.DeviceConnection.pair()會發起一次金鑰交換,產生一條加密的連結;bond=True(預設值)會保存金鑰,讓後續連線跳過交握。若沒有mitm=True與可用的 IO 能力,加密能抵禦被動竊聽者,但無法抵禦原始配對期間的主動重新導向。
這已足以撰寫各種相機應用:以 peripheral 身分發布狀態、以 central 身分讀取感測器資料、透過 BLE 把即時數值推送到手機、以一個配對加綁定的步驟保護連結,並且——對於少見的大量傳輸情況——離開 GATT 改用一條 L2CAP 通道。
11.14.1. 疑難排解¶
BLE 失敗大多是兩端期望之間的不一致,而手機端的檢查工具是看出誰的期望出錯最快的方式。標準工具是 nRF Connect for Mobile(Nordic Semiconductor,於 Android 與 iOS 上免費提供):它能掃描、連線、走訪 GATT 資料庫、讀寫特徵並訂閱通知——因此相機端的行為可以被獨立測試,完全不必撰寫一個搭配的應用程式。
常見的失敗模式:
「我的裝置在掃描器中出現,但無法連線。」 最常見的原因是廣播封包帶有
connectable=False(broadcaster 模式),或是先前的連線仍然開著、而 cam 已經越過了aioble.advertise()。在 advertise 呼叫前後加上 print 陳述式來確認。「exchange_mtu(512) 執行了,但我的通知仍被限制在 20 個位元組。」 協商出的 MTU 是
min(local, peer)——手機或 central 函式庫可能沒有在它那一側請求更大的 MTU,這種情況下連線就會維持在 23。在exchange_mtu()回傳後檢查mtu。另外請注意,exchange_mtu()每條連線只能作用一次;請在第一次大型操作之前呼叫它。「配對失敗,出現一個一般性錯誤。」 兩個常見的元兇:IO 能力不相符(在一台宣告
io=3/ 無輸入無輸出的 cam 上要求mitm=True——沒有辦法確認數字代碼,所以配對引擎放棄),以及在對端要求時 cam 上的牆鐘時間嚴重錯誤。請在首次配對嘗試前用ntptime.settime()設定時鐘。「通知從未送達用戶端。」 依序檢查兩件事:(a) 該特徵宣告時有沒有帶
notify=True?——屬性位元必須在伺服器端設定;(b) 用戶端有沒有呼叫subscribe()?——若未寫入 Client Characteristic Configuration Descriptor (CCCD),伺服器會被告知沒有用戶端想要通知,於是默默地將它們丟棄。「廣播出去的名稱被截斷或不見了。」 廣播酬載是 31 個位元組,而 flags + 服務 UUID + 外觀等欄位各自會從中占去一些位元組。一個很長的
name=再加上數個服務 UUID 就會溢出。要嘛縮短名稱,要嘛使用主動掃描,讓掃描回應(另外 31 個位元組)承載溢出的部分。nRF Connect 會分別顯示這兩半,使這個切分一目了然。「L2CAP connect 立即引發例外。」 通常是 PSM 不相符——兩端必須在頻外協議好相同的 PSM 編號。
L2CAPConnectionError會以其第一個引數帶出 Bluetooth 狀態碼;狀態2(「PSM not supported」)就是線索。「已綁定的連線在每次重新連線時仍觸發完整的配對交握。」 是啟動時沒有呼叫
aioble.security.load_secrets()。沒有它的話,儲存的金鑰雖在快閃記憶體上卻從未被載入記憶體,因此對端的身分未知,每次都從頭跑配對。
當其他方法都失敗時,較低階的 bluetooth 模組公開了一個 IRQ 回呼,它會在每個底層事件發生時觸發;短暫訂閱它並印出這些事件,相當於替 cam 端做一次 Wireshark 追蹤。
11.14.2. 日後使用本參考資料¶
請把這些 Bluetooth 章節當作參考材料;為了查清楚 peripheral 廣播酬載的確切布局,或是 central 掃描並訂閱的流程,回來翻閱正是它預期的用途。當問題只是「這個呼叫的確切名稱是什麼」時,aioble --- 非同步 BLE 與 bluetooth --- 低階藍牙 參考頁面把每一個方法、旗標與常數都列在同一個地方。