11.11. L2CAP 通道¶
GATT 是一種鍵/值模型。它所提供的操作(讀取、寫入、通知、指示)一次只搬移一個短小的數值,而它們能承載的最大單一酬載,就是協商出來的 MTU 所允許的大小——頂多幾百個位元組。這對於感測器讀數、命令暫存器與狀態旗標運作良好,但碰到數 KB 或數 MB 時就行不通了:把一大塊資料拆成數百次小寫入,所耗費的往返次數,遠比無線電本身的速度要慢得多。
對於大量資料的傳輸流——相機串流給手機的擷取影格、空中下載的更新映像檔、量測資料的批次匯出——BLE 提供了另一條路徑:邏輯鏈路控制與調適協定(Logical Link Control and Adaptation Protocol,L2CAP)。L2CAP 位於鏈路層與 GATT 之間,讓應用程式得以在同一條無線電鏈路之上宣告專屬的連線導向通道。這個通道是一條採用信用額度流量控制的位元組路徑,具有大得多的每封包 MTU,且中間沒有 GATT 框架。
11.11.1. 何時使用 L2CAP¶
在以下情況下,L2CAP 通道是合適的工具:
傳輸量超過幾百個位元組。
兩端都知道將會使用 L2CAP 通道(它不會出現在廣播酬載中;用戶端必須透過頻外管道得知該通道的協定/服務多工器,即 PSM,編號)。
應用程式願意放棄 GATT 的便利之處:沒有依 UUID 內建的可定址性、無法透過標準應用程式被用戶端探索、沒有通知。
在以 aioble 為基礎的應用程式中,最常見的情況是在兩個都知曉 PSM 約定的軟體之間搬移二進位資料塊——自訂的相機對手機協定、一對彼此通訊的 openmv 相機,或是某個周邊 GATT 服務底下的內部韌體更新路徑。
其餘的一切,請繼續使用 GATT。一個簡短的狀態、一個控制暫存器、一筆感測器讀數——這些全都應該放在特徵裡。
11.11.2. 建立通道¶
L2CAP 是執行在既有的 aioble.DeviceConnection 之上,因此 GAP 端的探索/廣播/連線流程與 GATT 完全相同。一旦雙方都持有連線,一方便在某個 PSM 上監聽,另一方則連線過去。
PSM 不過是一個小整數。Bluetooth SIG 將範圍的低段保留作標準化用途(0x0001-0x007F);應用程式專屬的通道請使用動態範圍內的編號(0x0080-0x00FF 用於固定 PSM,0x0040 起通常可自由用於自訂用途)。雙方必須事先就此數值達成共識。
L2CAP 通道上的 MTU,是任一方在單次 send() 中將傳遞的最大單一 SDU(Service Data Unit)——而非 BLE 鏈路 MTU。Aioble 會自動將較大的酬載分段。相機的 BLE 主機將 L2CAP MTU 上限定在 1017 位元組;``512`` 是個合理的預設值,能在雙方都留有餘裕又不耗盡 RAM。
在監聽端(例如作為周邊的相機):
async def serve_l2cap(connection, image_bytes):
channel = await connection.l2cap_accept(psm=0x80, mtu=512)
async with channel:
# image_bytes is a bytearray -- e.g. csi0.snapshot().bytearray()
# or a compressed JPEG buffer. send() fragments into MTU-sized
# chunks automatically and awaits flow-control credits between.
await channel.send(image_bytes)
await channel.flush()
在連線端(例如手機或中央裝置):
async def open_l2cap(connection, total_bytes):
channel = await connection.l2cap_connect(psm=0x80, mtu=512)
async with channel:
image_bytes = bytearray(total_bytes)
view = memoryview(image_bytes)
received = 0
while received < total_bytes:
n = await channel.recvinto(view[received:])
if n == 0:
break
received += n
return image_bytes
l2cap_accept() blocks
until the peer connects (or timeout_ms fires);
l2cap_connect() blocks
until the listener accepts (or fails). Both return an
aioble.L2CAPChannel -- itself an async context
manager that closes the channel on exit.
11.11.3. 傳送與接收¶
通道上的兩個主要操作是 send()(將位元組寫給對端)與 recvinto()(讀入一個預先配置的緩衝區)。兩者都是協程。
send()會將緩衝區分段成 MTU 大小的區塊,並在各區塊之間等待鏈路層的流量控制信用額度。從應用程式的角度來看,一次長傳送就是一個await;在內部,它可能會排入許多封包,並在每當對端的接收信用額度用盡時暫停。recvinto()會以任何可用的資料(最多到通道的 MTU)填滿所傳入的緩衝區,並回傳位元組數。若沒有可用資料則會等待。available()在有已緩衝且就緒的資料時會同步回傳True——適用於不暫停執行的輪詢。flush()會等待直到任何尚未完成的傳送都已完整傳輸給控制器。
L2CAP 通道在「位元組依序抵達且不遺失」這個意義上類似串流,但單次 send 的邊界會被保留——每個 SDU 都從單次 recvinto 中取出。這與 TCP 不同,在 TCP 中一次 send() 的邊界可能會分散到多次 recv() 呼叫上。
11.11.4. 斷線處理¶
通道會在三種情況下消失:任一方呼叫 disconnect()、底層的 GAP 連線中斷,或 L2CAP 層級的斷線通知抵達。進行中的操作會引發 aioble.L2CAPDisconnectedError。與 GATT 端一樣,這會在當時正在等待的協程中以例外形式浮現,而 async with channel 區塊則乾淨地離開。
如果某個通道因 GAP 層級的斷線而變得無法觸及,應用程式會回到廣播或掃描的迴圈,方式與面對 GATT 斷線時相同。
11.11.5. 記憶體成本¶
較大的 MTU 與較長的佇列會在雙方耗用更多 RAM。一個 512 位元組的 MTU 加上每通道一個接收緩衝區,每條通道約為 1 KB——若同時開啟數條通道,對一台小型相機而言並非毫無代價。請每條連線只用一條通道,並挑選與預期訊息大小相符的 MTU;每個 DeviceConnection 預設一個 L2CAPChannel,對大多數應用程式而言已足夠。
L2CAP 是 BLE 的安全閥。GATT 是幾乎每個應用程式最先選用的方式,而本節其餘的中央/周邊範例也都堅持使用 GATT。當應用程式的需求超出鍵/值模型時,這種通道風格的 API 就是答案。