11.8. aioble 模組

Bluetooth 核心規格提供了一套詞彙,對應到兩個 MicroPython 模組。

  • bluetooth——與 BLE 控制器的低階綁定。它是同步的、透過 IRQ 風格的回呼函式以事件驅動,並圍繞著位元組緩衝區、控制代碼與最基本的 GATT 原語來建構。它將協定原貌地呈現出來,而非以 Python 應用程式想要使用的形式。

  • aioble——一個較高階的包裝,以 Python 撰寫在 bluetooth 之上,將每個遠端操作轉化為 asyncio 協程,並將每個 BLE 物件(服務、特徵、連線、掃描結果、L2CAP 通道)轉化為符合人因工學的 Python 類別。掃描變成非同步迭代器;連線變成非同步情境管理器;通知變成可等待物件。

11.8.1. 何時該採用較低階的模組

對於兩種狹隘的情況,bluetooth 仍是正確的答案:

  • 你正在撰寫的,正是 aioble 本身所建構出來的那類程式碼——一個需要對協定進行 IRQ 層級控制的新模式。

  • 你執行的硬體目標上沒有 aioble 套件,而圍繞控制器的一層薄墊片是唯一的選擇。

對於每一個相機應用程式,aioble 都是正確的答案。

11.8.2. aioble 程式的構成元件

每個以 aioble 為基礎的應用程式,無論扮演哪些角色,都有一小組可動的部件。

  • 一個長時間執行的 asyncio 事件迴圈。aioble 中的一切都是協程,所以應用程式被建構成單一事件迴圈上的一個或多個任務。關於迴圈、任務與例外的詳細內容,請參見 Asyncio

  • 一具已開啟的無線電。aioble 會在首次使用時隱式啟用 BLE 無線電,但也可以透過 aioble.config()(它會在確保無線電已啟動後轉發給 bluetooth.BLE.config())顯式控制,並以 aioble.stop() 將其關閉。

  • 同時進行一個或多個角色。在周邊端:一組已註冊的 GATT 服務(參見 aioble.register_services())與一個執行中的 aioble.advertise() 協程。在中央端:一個執行中的 aioble.scan() 迭代器,或一個待處理的 aioble.Device.connect()。無線電會對這些工作進行多工;應用程式則將每個角色視為一個獨立的任務。

11.8.3. 一個最小的周邊

最小而實用的 aioble 程式——一個廣播單一唯讀特徵的周邊——很簡短:

import aioble
import asyncio
import bluetooth

SERVICE_UUID = bluetooth.UUID(0x181A)            # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E)               # Temperature

service = aioble.Service(SERVICE_UUID)
temp = aioble.Characteristic(service, TEMP_UUID, read=True)
aioble.register_services(service)

async def main():
    while True:
        conn = await aioble.advertise(
            interval_us=250000,
            name="openmv-temp",
            services=[SERVICE_UUID],
        )
        async with conn:
            await conn.disconnected()

asyncio.run(main())

一個只做連線並讀取一次、別無其他的中央裝置,同樣簡短:

import aioble
import asyncio
import bluetooth

SERVICE_UUID = bluetooth.UUID(0x181A)
TEMP_UUID = bluetooth.UUID(0x2A6E)

async def main():
    device = None
    async with aioble.scan(duration_ms=5000, active=True) as scanner:
        async for result in scanner:
            if SERVICE_UUID in result.services():
                device = result.device
                break
    if device is None:
        return

    async with await device.connect() as conn:
        service = await conn.service(SERVICE_UUID)
        char = await service.characteristic(TEMP_UUID)
        print(await char.read())

asyncio.run(main())

兩個程式都約十五行,涵蓋了從「無線電關閉」到「完成有用工作」的完整流程。

11.8.4. 關閉無線電

在電池供電的相機上,BLE 無線電是預算中最大的可裁量耗電項目。有兩個旋鈕很重要。

第一個是隱式的:aioble 會在首次使用時啟用無線電,而無線電會在排定的事件(廣播脈衝、連線事件、掃描視窗)之間自動進入睡眠。在 aioble.advertise() / aioble.scan() 上挑選較長的間隔,並在 connect() 時協商較長的連線間隔,能讓無線電按比例關閉更長的時間。廣告與掃描 中的廣播表格是這方面的實用指南。

第二個是顯式的關閉:

import aioble

await do_burst_of_ble_work()
aioble.stop()                             # radio deactivated; in-flight tasks unwound
await asyncio.sleep(60)                   # sleep with the radio off
# ... next aioble call brings the radio back up automatically

aioble.stop() 會停用底層的 BLE 無線電,並拆除任何進行中的事物——開啟的連線會中斷、掃描器與廣播器會取消、L2CAP 通道會關閉。原本在等待這些操作的協程會引發其慣常的例外(DeviceDisconnectedError 及其同伴),而這正是周圍那些 async with 區塊所設計用來處理的清理機制。在此之後再呼叫任何 aioble 協程,都會從冷態重新啟用無線電。

一台週期性、電池供電的感測器相機,典型的模式是:

  • 依排程喚醒(計時器、動作感測器、按鈕)。

  • 執行那一陣 BLE 工作——廣播、接受連線、推送數值、斷線。

  • 呼叫 aioble.stop() 並睡眠到下次喚醒。

11.8.5. aioble 不做哪些事

aioble 刻意只涵蓋 GATT、GAP 與 L2CAP——應用程式所使用的那幾層。有三個部分不在其範疇內:

  • 鏈路層以下的任何事物。 通道選擇、跳頻、封包確認,以及鏈路層加密,全都發生在 BLE 連接埠與控制器矽晶片內部;:mod:aioble 不會在那個層級提供掛接點。

  • 傳統 Bluetooth。 aioble 僅支援 BLE。音訊鏈路、RFCOMM、A2DP,以及其他傳統設定檔的功能,都不屬於此 API 的一部分。

  • Bluetooth Mesh。 Bluetooth SIG 的網狀網路層(位於 BLE 廣播之上的一個獨立堆疊)並未在相機上實作。相機可以廣播與觀察,但無法參與網狀網路中的中繼/好友/代理角色。

11.8.6. 例外

aioble 會產生四種例外型別。每一種都從某個協程內部觸發——該協程在出錯時正等待著某個操作;當這些例外向外傳播時,async with 區塊會乾淨地解開。

  • aioble.DeviceDisconnectedError——在某個 GATT 操作(readwritenotifiedindicatedsubscribeexchange_mtu 等)進行中時,與對端的 BLE 鏈路中斷了。會在當時正在等待的那個協程內部引發。這是目前為止最常見的例外;任何應在連線遺失時重新連線的程式碼,都應捕捉它。

  • aioble.GattError——某個 GATT 操作抵達了對端,但以非零的 ATT 狀態完成(帶回應的寫入遭拒、指示未被確認、讀取未獲許可等)。狀態碼位於例外的 _status 屬性上。

  • aioble.L2CAPDisconnectedError——在某個 send()recvinto()flush() 進行中時,L2CAP 通道中斷了。可能是任一方關閉了通道,或底層的 GAP 連線消失了。

  • aioble.L2CAPConnectionError——當監聽端拒絕,或控制器未能完成通道建立時,由 l2cap_connect() 引發。Bluetooth 狀態碼是第一個位置引數。

接受顯式 timeout_ms 的操作(連線/探索/讀取/寫入/配對等呼叫,加上作為包裝的 timeout()),在操作完成前期限屆滿時,還會額外從 asyncio 引發 asyncio.TimeoutError