11.9. 作為周邊裝置

相機端最常見的 BLE 模式是作為 周邊裝置(peripheral)——發布一個小型 GATT 資料庫、廣播自身存在、接受來自手機或夥伴裝置的連線,並將數值串流給連線另一端的對象。

11.9.1. 建構 GATT 資料庫

周邊裝置在啟動時做的第一件事——甚至在開啟無線電之前——就是建構它打算公開的資料庫,為每個服務(service)與特徵(characteristic)建立物件,然後將它們全部註冊起來::

import aioble
import bluetooth

ENV_SERVICE = bluetooth.UUID(0x181A)              # Environmental Sensing
TEMP_UUID = bluetooth.UUID(0x2A6E)                # Temperature
HUMID_UUID = bluetooth.UUID(0x2A6F)               # Humidity

env = aioble.Service(ENV_SERVICE)
temp_char = aioble.Characteristic(
    env, TEMP_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)
humid_char = aioble.Characteristic(
    env, HUMID_UUID,
    read=True, notify=True, initial=b"\\x00\\x00",
)

aioble.register_services(env)

每個 aioble.Characteristic 只要在建構時將其服務作為第一個引數傳入,就會附加到該服務上。布林關鍵字引數(readwritewrite_no_responsenotifyindicate)用於選擇允許用戶端執行哪些 GATT 操作;傳入 False(預設值)表示不設定該屬性位元。

aioble.register_services() 會將組裝好的樹狀結構提交給 GATT 伺服器。它必須在任何 aioble.advertise() 開始之前呼叫一次;再次呼叫它會取代先前的資料庫。

11.9.2. 廣播

資料庫就緒後,廣播只需一次協程呼叫,它會等待連線::

async def serve_one():
    connection = await aioble.advertise(
        interval_us=250000,
        name="openmv-env",
        services=[ENV_SERVICE],
        appearance=0x0540,           # Generic Sensor
    )

這些關鍵字引數直接對應到廣播酬載欄位。name 是本機名稱欄位;services 是裝置所代管的服務 UUID 清單(手機端的掃描器可依此過濾);appearance 是來自標準 16 位元外觀值的提示,讓中央裝置(central)能顯示合適的圖示。製造商專屬資料則透過 manufacturer=(company_id, data_bytes) 傳入。

還有少數較不常用的關鍵字涵蓋了其餘的廣播旗標空間:

  • connectable=False——僅廣播模式(從不接受任何連線)。這是信標式(beacon)酬載的正確選擇。

  • limited_disc=True——使用 受限可探索(limited discoverable) 旗標而非 一般可探索(general discoverable);某些作業系統在配對 UI 中會對這兩者做不同處理。

  • adv_data / resp_data——當應用程式需要完全掌控版面配置時,可傳入原始位元組。

  • timeout_ms——在固定時間後放棄。預設是永遠持續廣播。

當中央裝置連線時,aioble.advertise() 會回傳產生的 aioble.DeviceConnection。周邊裝置此時會停止廣播。

11.9.3. 服務單一用戶端

周邊裝置的主迴圈通常長這樣:

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        print("connected:", connection.device.addr_hex())
        async with connection:
            await connection.disconnected()
        print("disconnected; advertising again")

asyncio.run(serve())

async with connection 會讓中斷連線的清理自動化。disconnected() 是一個協程,會暫停直到任一方結束連線——這是讓周邊裝置持續服務直到中央裝置離開的乾淨做法,之後再循環回去進行下一輪廣播。

11.9.4. 更新特徵

周邊裝置使用 aioble.Characteristic.write() 更新本機 GATT 資料庫::

temp_char.write(b"\\x9a\\x09")              # 24.58 deg C as sint16, 0.01 units

這會改變任何用戶端下次 read 時會回傳的數值。但它本身 不會 推送新值——已訂閱的用戶端在用戶端輪詢或周邊裝置發送明確通知之前,都不會看到任何變化。

推送端是同一次呼叫上的單一關鍵字::

temp_char.write(temp_bytes, send_update=True)

send_update=True 會通知(或指示)每個已訂閱此特徵的用戶端。大多數感測器式的程式碼都位於一個每連線(per-connection)任務中,該任務迴圈讀取感測器並大約每秒一次以 send_update=True 寫入該值::

async def stream_temperature(connection):
    while connection.is_connected():
        temp_char.write(encode_temperature(read_sensor()), send_update=True)
        await asyncio.sleep(1)

async def serve():
    while True:
        connection = await aioble.advertise(
            interval_us=250000,
            name="openmv-env",
            services=[ENV_SERVICE],
        )
        async with connection:
            asyncio.create_task(stream_temperature(connection))
            await connection.disconnected()

如果你希望將通知導向某個特定用戶端,而非整個訂閱集合(例如針對該用戶端命令的連線私有回應),aioble.Characteristic.notify()indicate() 可接受一個 DeviceConnection 引數以及一個選用的酬載。

11.9.5. 接收寫入

另一個方向——用戶端 寫入 特徵——在特徵以 write=Truewrite_no_response=True 建構時即可使用。周邊裝置以 aioble.Characteristic.written() 等待下一次寫入::

cmd_char = aioble.Characteristic(env, CMD_UUID, write=True, capture=True)

async def handle_commands():
    while True:
        connection, data = await cmd_char.written()
        print("command from", connection.device.addr_hex(), "=", data)

在沒有 capture=True 的情況下,written() 僅回傳進行寫入的連線;新值存放在特徵的後備緩衝區(backing buffer)中,應用程式以 read() 擷取它。如果在應用程式讀取第一個值之前第二次寫入就到達,第二個值會在緩衝區中 覆寫 第一個值,原始值便遺失——written() 仍會喚醒應用程式,但只在「有新東西」時喚醒一次,而非每次寫入都喚醒一次。

capture=True 關鍵字可解決這個問題。每個傳入的寫入都會被附加到一個模組層級的佇列中,且 written() 會為每一次個別寫入回傳一個 (connection, data) 元組——應用程式迴圈會依到達順序確切地看到每一個寫入一次。這有兩個實務上的後果:

  • 該佇列是有界的,且 由裝置上每個啟用 capture 的特徵共享。短暫連續的寫入爆發是可以容忍的;持續的溢位(寫入到達速度快於應用程式排空的速度)會靜默地丟棄 最舊 的佇列項目,且某一特徵上的爆發性流量可能會逐出另一特徵的待處理項目。

  • 對於每個值都很重要的命令式寫入,請選用 capture=True。對於只關心最新值的狀態式特徵,則保持關閉。

如果來自用戶端的讀取應由按需執行的程式碼回應,而非靜態值,請覆寫 on_read()。當讀取到來時會同步呼叫該方法;回傳 0 以允許讀取(將會送出來自 write() 的目前值),或回傳非零的 ATT 錯誤碼以拒絕它::

import time

_ATT_ERR_READ_NOT_PERMITTED = const(0x02)
_MIN_READ_INTERVAL_MS = const(1000)            # at most once per second

class TempChar(aioble.Characteristic):
    _last_read_ms = 0

    def on_read(self, connection):
        now = time.ticks_ms()
        if time.ticks_diff(now, self._last_read_ms) < _MIN_READ_INTERVAL_MS:
            return _ATT_ERR_READ_NOT_PERMITTED
        self._last_read_ms = now
        self.write(encode_temperature(read_sensor()))
        return 0

temp_char = TempChar(env, TEMP_UUID, read=True)

該回呼函式會在 GATT 堆疊服務該讀取之前,立即採樣感測器並更新特徵的值,因此用戶端總是看到最新資料。速率限制可防止用戶端以快於可採樣的速度反覆操作感測器——任何在一秒冷卻時間內的讀取都會被以 Read Not Permitted ATT 錯誤退回,而非回傳過時的值。

11.9.5.1. 更大的後備緩衝區——BufferedCharacteristic

一般 Characteristic 的後備緩衝區寬度為 20 位元組——這是預設 23 位元組 MTU 下的實務上限。若用戶端寫入超過此大小的值到一般特徵中,其值會被截斷。對於較大的傳入值,或為了排入應用程式迴圈稍後才會處理的連續寫入,請將特徵宣告為 BufferedCharacteristic 並預先選定緩衝區大小::

blob = aioble.BufferedCharacteristic(
    service, BLOB_UUID,
    max_len=512, append=True,
    write=True, capture=True,
)

async def receive_blob():
    while True:
        connection, chunk = await blob.written()
        handle_chunk(connection, chunk)

有兩個旋鈕將它與一般的 Characteristic 區分開來:

  • max_len 是後備緩衝區的大小(以位元組計)。請依用戶端預期會進行的最大單次寫入(在 MTU 協商之後)來選定它。

  • append=True 會讓連續的寫入 附加 進緩衝區而非覆寫——這對於接收跨多次寫入到達的值很有用(韌體更新區塊、記錄行)。在 append=False 時,緩衝區的行為就像一般特徵,只是更寬。

所有其他建構子旗標(readwritenotifyindicatecaptureinitial)都會原封不動地轉送給底層的特徵。

11.9.6. 標準服務與 SIG 指定的 UUID

堅持使用指定編號的 UUID(電池服務為 0x180F、環境感測為 0x181A、心率為 0x180D 等等)意味著手機的通用 Bluetooth 選單或任何第三方掃描器應用程式都能在不需任何自訂用戶端程式碼的情況下辨識裝置的用途。每個標準特徵內部的位元組版面配置也由規格固定——電池電量(0x2A19)是單一位元組 0..100;溫度(0x2A6E)是以 0.01 攝氏度為單位的小端序 sint16。對於 符合標準服務的應用程式,請產生一次 128 位元 UUID,並在裝置的各服務與特徵中使用它。

只發布自訂 UUID 的周邊裝置仍然沒有問題——它只需要一個知道那些 UUID 的自訂用戶端應用程式。

備註

BLE 數值 處處皆為小端序——GATT 規格、每個標準特徵、每個廣播欄位皆然。多位元組整數在線路上以低位元組為先傳輸。struct 格式字串中的 < 前綴正是你在編碼/解碼時所需要的("<h""<H""<I" 等等);在小端序 MCU 上使用預設的原生位元組順序目前碰巧可行,但明確寫出 < 才是安全的習慣。

11.9.7. 背後的無線電

從第一個 aioble 協程接觸到無線電的那一刻起,無線電就已開啟。在中央裝置連線之前,周邊裝置的時間花在短暫的廣播爆發與睡眠之間切換;連線之後則遵循協商出的連線間隔。周邊裝置每次廣播都會付出小小的耗電成本,因此 aioble.advertise()interval_us 的選擇,是周邊裝置在探索延遲與電池壽命之間取捨時最直接的旋鈕。